Skip to main content

Mountain/IPC/
DevLog.rs

1//! # DevLog - Tag-filtered development logging
2//!
3//! Controlled by `Trace` environment variable.
4//! The same tags work in both Mountain (Rust) and Wind/Sky (TypeScript).
5//!
6//! ## Usage
7//! ```bash
8//! Trace=vfs,ipc ./Mountain          # only VFS + IPC
9//! Trace=all ./Mountain              # everything
10//! Trace=short ./Mountain            # everything, compressed + deduped
11//! Trace=terminal,exthost ./Mountain # terminal + extension host
12//! ./Mountain                               # nothing (only normal log!() output)
13//! ```
14//!
15//! ## Short Mode
16//!
17//! `Trace=short` enables all tags but compresses output:
18//! - Long app-data paths aliased to `$APP`
19//! - Consecutive duplicate messages counted (`(x14)` suffix)
20//! - Rust log targets compressed (`D::Binary::Main::Entry` → `Entry`)
21//!
22//! ## File sink
23//!
24//! When `Record=1` (or, in debug builds, `Trace` is set),
25//! every emitted line is mirrored to:
26//!
27//! ```
28//! ~/Library/Application Support/<bundle>/logs/<YYYYMMDDTHHMMSS>/Mountain.dev.log
29//! ```
30//!
31//! The timestamp directory follows Tauri's `tauri-plugin-log` format, so
32//! the dev log sits next to the plugin's own log file for the same boot
33//! (one directory per process start). Use `Record=0` to force-
34//! disable even in debug. File writes are flushed per line so `tail -f`
35//! shows live output.
36//!
37//! ## Tags (38 granular tags across all Elements)
38//!
39//! | Tag           | Scope                                               |
40//! |---------------|-----------------------------------------------------|
41//! | `vfs`         | File stat, read, write, readdir, mkdir, delete, copy|
42//! | `ipc`         | IPC routing: invoke dispatch, channel calls          |
43//! | `config`      | Configuration get/set, env paths, workbench config   |
44//! | `lifecycle`   | Startup, shutdown, phases, window events             |
45//! | `storage`     | Storage get/set/delete, items, optimize              |
46//! | `folder`      | Folder picker, workspace navigation                  |
47//! | `exthost`     | Extension host: create, start, kill, exit info       |
48//! | `extensions`  | Extension scanning, activation, management           |
49//! | `terminal`    | Terminal/PTY: create, sendText, profiles, shell      |
50//! | `search`      | Search: findFiles, findInFiles                       |
51//! | `themes`      | Theme: list, get active, set                         |
52//! | `window`      | Window: focus, maximize, minimize, fullscreen        |
53//! | `nativehost`  | OS integration: process, devtools, shell             |
54//! | `clipboard`   | Clipboard: read/write text, buffer, image            |
55//! | `commands`    | Command registry: execute, getAll                    |
56//! | `model`       | Text model: open, close, get, updateContent          |
57//! | `output`      | Output channels: create, append, show                |
58//! | `notification`| Notifications: show, progress                        |
59//! | `progress`    | Progress: begin, end, report                         |
60//! | `quickinput`  | Quick input: showQuickPick, showInputBox             |
61//! | `workingcopy` | Working copy: dirty state                            |
62//! | `workspaces`  | Workspace: folders, recent, enter                    |
63//! | `keybinding`  | Keybindings: add, remove, lookup                     |
64//! | `label`       | Label service: getBase, getUri                       |
65//! | `history`     | Navigation history: push, goBack, goForward          |
66//! | `decorations` | Decorations: get, set, clear                         |
67//! | `textfile`    | Text file operations: read, write, save              |
68//! | `update`      | Update service: check, download, apply               |
69//! | `encryption`  | Encryption: encrypt, decrypt                         |
70//! | `menubar`     | Menubar updates                                      |
71//! | `url`         | URL handler: registerExternalUriOpener               |
72//! | `grpc`        | gRPC/Vine: server, client, connections               |
73//! | `cocoon`      | Cocoon sidecar: spawn, health, handshake             |
74//! | `bootstrap`   | Effect-TS bootstrap stages                           |
75//! | `preload`     | Preload: globals, polyfills, ipcRenderer             |
76//!
77//! ### Batch 1 diagnostic tags (Row 1-3 fix surfaces)
78//!
79//! Narrow tags added to isolate the architectural gaps surfaced by the
80//! analysis table. Enable selectively with e.g.
81//! `Trace=notif-drop,git,tree-view` to filter to just the
82//! subsystem under investigation.
83//!
84//! | Tag                 | Scope                                                                 |
85//! |---------------------|-----------------------------------------------------------------------|
86//! | `notif-drop`        | Notifications that hit the `_ => {}` default arm (dropped silently)   |
87//! | `provider-register` | Accepted `register_*_provider` notifications + handle + extension id  |
88//! | `channel-stub`      | Wind/Output `TauriMainProcessService.call` stub-hit vs route vs miss  |
89//! | `git`               | `localGit` channel, `GitExec` RPC, SCM provider group updates         |
90//! | `tree-view`         | `tree.register`, `GetTreeChildren`, `sky://tree-view/create` emit     |
91//!
92//! ### Batch 2 diagnostic tags
93//!
94//! | Tag              | Scope                                                                  |
95//! |------------------|------------------------------------------------------------------------|
96//! | `sky-emit`       | Mountain → Wind/Sky `sky://` emits: channel, payload bytes, ok/fail    |
97//! | `config-prime`   | Configuration cache: manifest pre-populate, subtree synthesise         |
98//! | `ext-activate`   | Per-extension activate: start, outcome (ok/fail/skip), duration        |
99//! | `breaker`        | Cocoon `MountainClientService` circuit-breaker state transitions        |
100//! | `cel-dispatch`   | SkyBridge `cel:*` CustomEvent dispatch + consumer-present flag         |
101//!
102//! ### Batch 3 diagnostic tags
103//!
104//! Added 2026-04-23 late after Batches 3-6 wired notification handlers,
105//! git channel, tree-view dataProvider forwarding, and medium stub
106//! backfill. These tags catch the subsystems the new traffic passes
107//! through so regressions surface as silent tag counts going to zero
108//! (or, for `tauri-invoke`, per-invoke latency spikes).
109//!
110//! | Tag                | Scope                                                                   |
111//! |--------------------|-------------------------------------------------------------------------|
112//! | `ext-scan`         | Extension scanner decisions: is-builtin vs user, skip reasons, counts   |
113//! | `scheme-assets`    | `vscode-file://` / `vscode-resource://` request routing, MIME, bytes    |
114//! | `preload-shim`     | Wind `Preload.ts` globals wiring, VS Code `ipcRenderer` polyfill install|
115//! | `tauri-invoke`     | Per-invoke method + duration (augments `ipc`'s paired invoke/done lines)|
116//! | `bootstrap-stage`  | Cocoon `Effect/Bootstrap.ts` stage timings (start/ok/fail per phase)    |
117//!
118//! ### Batch 4 diagnostic tags
119//!
120//! Added 2026-04-24 alongside the `workspace.fs` tier-split refactor.
121//! Cocoon's `WorkspaceNamespace/FileSystemRoute.ts` now chooses between
122//! Tier A (`node:fs/promises` in-process) and Tier C (Mountain `FileSystem.*`
123//! gRPC) per URI scheme + custom-provider claim. The tag surfaces every
124//! decision so empirical workload profiling confirms the split is paying
125//! off - `grep 'route=native'` / `grep 'route=mountain'` buckets per run.
126//! Emitted from Cocoon stdout, picked up by Mountain's `[DEV:COCOON]`
127//! stdout tail with the standard `[DEV:FS-ROUTE]` prefix.
128//!
129//! | Tag                | Scope                                                                   |
130//! |--------------------|-------------------------------------------------------------------------|
131//! | `fs-route`         | `workspace.fs.*` + `openTextDocument` native-vs-mountain routing        |
132//! | `cmd-route`        | `commands.executeCommand` local-vs-mountain routing                     |
133//! | `dual-track`       | Mountain-first / Node-fallback progressive Rust migration dispatches    |
134//!
135//! ### `dual-track` tag usage pattern
136//!
137//! Every Cocoon shim that wraps `TryMountainThenNode` logs one of four
138//! decisions per dispatch:
139//!
140//!   - `route=mountain`      Mountain handled it (Rust code path served the
141//!     call)
142//!   - `route=node-fallback` Build-time manifest says no Mountain handler (or
143//!     runtime confirmed "Unknown method: X"); Cocoon's Node / stock-VS-Code
144//!     implementation served it
145//!   - `route=unavailable`   Tier-4: no tier covers the method. Extension
146//!     receives typed `NotImplementedError`; build surfaces this as a
147//!     feature-gap
148//!   - `route=error`         Tier failed unexpectedly; error propagated
149//!
150//! Grep the stream to see which `vscode.*` methods Mountain doesn't yet
151//! cover - that set IS the Rust migration TODO list. A method moves from
152//! `node-fallback` to `mountain` automatically the moment Mountain lands
153//! its Rust handler AND the manifest is regenerated. `route=unavailable`
154//! rows are the apologise-to-user list.
155//!
156//! A single `[DEV:DUAL-TRACK] manifest mountain=N stockLift=M bespoke=K`
157//! line prints at Cocoon boot with the build-time tier coverage
158//! generated by `Maintain/Script/GenerateRouteManifest.sh`.
159
160use std::{
161	fs::{File, OpenOptions, create_dir_all},
162	io::{BufWriter, Write as IoWrite},
163	path::PathBuf,
164	sync::{
165		Mutex,
166		OnceLock,
167		atomic::{AtomicBool, Ordering},
168	},
169	time::{SystemTime, UNIX_EPOCH},
170};
171
172static ENABLED_TAGS:OnceLock<Vec<String>> = OnceLock::new();
173static SHORT_MODE:OnceLock<bool> = OnceLock::new();
174
175// ── File sink ────────────────────────────────────────────────────────────
176//
177// Mirrors every `dev_log!` line to a timestamped file under the app's
178// `logs/<YYYYMMDDTHHMMSS>/` directory so long sessions can be inspected
179// post-mortem without scrolling terminal history. Enable with:
180//
181//     Record=1     # explicit opt-in
182//     Record=0     # explicit opt-out (wins over defaults)
183//
184// When unset, file logging is auto-enabled in debug builds iff
185// `Trace` itself is set to at least one tag. Release builds stay
186// silent unless the opt-in flag is present, to avoid surprise writes in
187// shipped binaries.
188//
189// Directory layout matches Tauri's tauri-plugin-log output (same parent
190// `logs/` and same `YYYYMMDDTHHMMSS` subdir format) so the two logs sit
191// side by side when the user greps one timestamp.
192
193static LOG_FILE:OnceLock<Mutex<Option<BufWriter<File>>>> = OnceLock::new();
194
195/// Decide whether the file sink should be active. Returns the final flag
196/// once per process; subsequent calls are cached.
197fn FileSinkEnabled() -> bool {
198	static ENABLED:OnceLock<bool> = OnceLock::new();
199	*ENABLED.get_or_init(|| {
200		match std::env::var("Record") {
201			Ok(Value) => matches!(Value.as_str(), "1" | "true" | "yes" | "on"),
202			Err(_) => {
203				// Auto-enable when Trace is set in a debug build.
204				cfg!(debug_assertions) && std::env::var("Trace").is_ok()
205			},
206		}
207	})
208}
209
210/// Resolve the log directory for this session:
211///   ~/Library/Application Support/<bundle>/logs/<YYYYMMDDTHHMMSS>/
212///
213/// The timestamp is picked once per process at first call. If the app-data
214/// prefix can't be detected (Tauri hasn't spawned yet, say), fall back to
215/// the system temp directory with the same naming - dev_log still works
216/// and the file ends up somewhere predictable.
217fn ResolveLogDirectory() -> PathBuf {
218	let Stamp = FormatTimestamp();
219	let Base = match AppDataPrefix() {
220		Some(Prefix) => PathBuf::from(Prefix).join("logs"),
221		None => std::env::temp_dir().join("land-editor-logs"),
222	};
223	Base.join(Stamp)
224}
225
226/// Session timestamp in local time, cached once per process. MUST match
227/// whatever `WindServiceHandlers.rs::"nativeHost:getEnvironmentPaths"`
228/// builds, because VS Code's file service writes `window1/output/*.log`
229/// into the directory that handler returns - if DevLog and VS Code use
230/// different timezones, `Mountain.dev.log` and the `window1/` subtree
231/// land in two sibling directories 2-3 hours apart, which makes every
232/// post-mortem investigation start with "which folder has the real
233/// log?". Picking `chrono::Local::now()` matches the VS Code convention
234/// (Tauri's tauri-plugin-log also writes local-time `YYYYMMDDTHHMMSS`).
235///
236/// The format string is deliberately identical to the handler's
237/// `"%Y%m%dT%H%M%S"`, and both sides pull from the same OnceLock via
238/// `SessionTimestamp()` so re-entrant calls from anywhere in the
239/// codebase produce the same string.
240pub fn SessionTimestamp() -> String {
241	static STAMP:OnceLock<String> = OnceLock::new();
242	STAMP
243		.get_or_init(|| chrono::Local::now().format("%Y%m%dT%H%M%S").to_string())
244		.clone()
245}
246
247fn FormatTimestamp() -> String { SessionTimestamp() }
248
249// `DaysToYMD` + `IsLeap` were previously used to build a UTC timestamp
250// string without pulling chrono into DevLog. Replaced by
251// `chrono::Local::now()` in `SessionTimestamp()` so this file agrees
252// with `WindServiceHandlers.rs::"nativeHost:getEnvironmentPaths"` on
253// the session-log directory. chrono is already a Mountain dependency,
254// so the vendored date math is dead weight now.
255
256/// Initialise the file sink on first call. Silently falls through to a
257/// disabled sink if the directory or file can't be created - the caller
258/// must never panic because of a log-file failure.
259fn InitFileSink() -> &'static Mutex<Option<BufWriter<File>>> {
260	LOG_FILE.get_or_init(|| {
261		if !FileSinkEnabled() {
262			return Mutex::new(None);
263		}
264		let Dir = ResolveLogDirectory();
265		if create_dir_all(&Dir).is_err() {
266			eprintln!("[DEV:LOG] Failed to create log directory {}", Dir.display());
267			return Mutex::new(None);
268		}
269		let Path = Dir.join("Mountain.dev.log");
270		match OpenOptions::new().create(true).append(true).open(&Path) {
271			Ok(File) => {
272				let mut Writer = BufWriter::with_capacity(64 * 1024, File);
273				// Header pins the boot-time context so every session's file
274				// is self-describing even without the surrounding terminal.
275				let Header = format!(
276					"# Land dev log - started {}, pid {}, tags={:?}, short={}\n",
277					FormatTimestamp(),
278					std::process::id(),
279					EnabledTags(),
280					IsShort(),
281				);
282				let _ = Writer.write_all(Header.as_bytes());
283				let _ = Writer.flush();
284				eprintln!("[DEV:LOG] File sink → {}", Path.display());
285				Mutex::new(Some(Writer))
286			},
287			Err(Error) => {
288				eprintln!("[DEV:LOG] Failed to open {}: {}", Path.display(), Error);
289				Mutex::new(None)
290			},
291		}
292	})
293}
294
295/// Force the file sink to initialize before any `dev_log!` has run.
296///
297/// `WriteToFile` is otherwise lazy - the log file is only opened the first
298/// time a tagged `dev_log!` call fires. When Mountain panics (or the
299/// webview traps the user with an early error) before the first enabled
300/// tag emits, the session log directory ends up with an empty shell and
301/// the post-mortem evidence is lost.
302///
303/// Call this once at the top of `Binary::Main::Fn()` - as early as the
304/// binary can reach - so the header line + `Record=1` opt-in
305/// are honoured even when nothing else ever logs. Harmless to call
306/// multiple times; the `OnceLock` inside `InitFileSink` gates it.
307pub fn InitEager() { let _ = InitFileSink(); }
308
309/// Append a single formatted line to the session's log file if the file
310/// sink is active. Swallows every error - dev_log must never crash.
311pub fn WriteToFile(Line:&str) {
312	let Sink = InitFileSink();
313	if let Ok(mut Guard) = Sink.lock() {
314		if let Some(Writer) = Guard.as_mut() {
315			let _ = Writer.write_all(Line.as_bytes());
316			if !Line.ends_with('\n') {
317				let _ = Writer.write_all(b"\n");
318			}
319			// Flush on every line - ordering/tail-f matters more than throughput
320			// for dev logs, and the BufWriter coalesces partial writes anyway.
321			let _ = Writer.flush();
322		}
323	}
324}
325
326// ── Path alias ──────────────────────────────────────────────────────────
327// The app-data directory name is absurdly long. In short mode, alias it.
328static APP_DATA_PREFIX:OnceLock<Option<String>> = OnceLock::new();
329
330/// Produce an identity signature for THIS running binary derived from
331/// `CARGO_PKG_NAME` (which Maintain sets to the long PascalCase product
332/// name before `cargo build`). Each profile produces a distinct signature
333/// - `_Debug_Mountain` → `.debug.mountain`, `_Compile_Mountain` →
334/// `.compile.mountain`, `_Bundle_Clean_Debug_ElectronProfile_Mountain`
335/// → `.debug.electron.profile.mountain` - so a candidate directory in
336/// `~/Library/Application Support/` can be disambiguated against every
337/// other `land.editor.*.mountain` leftover from prior runs.
338///
339/// Only the last three underscore-delimited segments are used: the
340/// leading `DevelopmentNodeEnvironment_MicrosoftVSCodeDependency_
341/// 22NodeVersion_Bundle_Clean` prefix is identical across profiles and
342/// doesn't help disambiguate, while the tail (`Debug_Mountain` vs
343/// `Compile_Mountain` vs `Debug_ElectronProfile_Mountain`) is where the
344/// per-profile identity lives.
345fn BinarySignature() -> String {
346	let PackageName = env!("CARGO_PKG_NAME");
347	let Segments:Vec<&str> = PackageName.split('_').collect();
348	let Take = Segments.len().min(4);
349	let Start = Segments.len().saturating_sub(Take);
350	// Each underscore-delimited segment is PascalCase (e.g. `ElectronProfile`,
351	// `MountainProfile`, `RestCompiler`). The Tauri identifier Maintain
352	// generates splits every PascalCase word on its capital boundary, so
353	// `ElectronProfile` → `electron.profile`, not `electronprofile`. Without
354	// the per-segment split here the signature becomes `electronprofile`
355	// and `ends_with` never matches the real Tauri app-data dir
356	// `…clean.debug.electron.profile.mountain`, so DevLog silently falls
357	// back to whichever other `*.mountain` directory `read_dir` yielded
358	// first - the bug that sent the electron-profile binary's logs into
359	// the compile-profile directory.
360	let Dotted:String = Segments[Start..]
361		.iter()
362		.flat_map(|Segment| SplitPascalCaseIntoWords(Segment))
363		.collect::<Vec<String>>()
364		.join(".")
365		.to_ascii_lowercase();
366	Dotted
367}
368
369/// Split a PascalCase / UPPERCASE string into lowercase component words,
370/// matching the tokenisation Maintain's `Build/Process.rs` applies when it
371/// stamps the Tauri `identifier`. Example: `ElectronProfile` →
372/// `["electron", "profile"]`; `22NodeVersion` → `["22", "node", "version"]`.
373/// Empty segments are filtered out.
374fn SplitPascalCaseIntoWords(Segment:&str) -> Vec<String> {
375	let mut Words:Vec<String> = Vec::new();
376	let mut Current = String::new();
377	let mut PrevWasUpper = false;
378	let mut PrevWasDigit = false;
379	for Ch in Segment.chars() {
380		let IsUpper = Ch.is_ascii_uppercase();
381		let IsDigit = Ch.is_ascii_digit();
382		let NeedBreak =
383			!Current.is_empty() && ((IsUpper && !PrevWasUpper) || (IsDigit != PrevWasDigit && !Current.is_empty()));
384		if NeedBreak {
385			Words.push(std::mem::take(&mut Current));
386		}
387		Current.push(Ch);
388		PrevWasUpper = IsUpper;
389		PrevWasDigit = IsDigit;
390	}
391	if !Current.is_empty() {
392		Words.push(Current);
393	}
394	Words.into_iter().filter(|Word| !Word.is_empty()).collect()
395}
396
397fn DetectAppDataPrefix() -> Option<String> {
398	let Home = std::env::var("HOME").ok()?;
399	let Base = format!("{}/Library/Application Support", Home);
400	let Signature = BinarySignature();
401
402	// Prefer a directory whose name ends with this binary's unique tail
403	// signature: that's the app-data directory Tauri created for THIS
404	// profile. Without this check, a user who has ever launched another
405	// profile (debug-electron, release-electron, …) will see DevLog
406	// writing into that stale directory while userdata still goes into
407	// the current one, producing an "empty logs folder" mystery.
408	let mut FirstMatchingMountain:Option<String> = None;
409	if let Ok(Entries) = std::fs::read_dir(&Base) {
410		for Entry in Entries.flatten() {
411			let Name = Entry.file_name();
412			let Name = Name.to_string_lossy().into_owned();
413			if !Name.starts_with("land.editor.") || !Name.contains("mountain") {
414				continue;
415			}
416			// Strict match: binary signature tail is a suffix of the dir name.
417			if Name.ends_with(&Signature) {
418				return Some(format!("{}/{}", Base, Name));
419			}
420			// Lossy match: some segment of the binary signature is contained
421			// in the dir name. Used only if no strict match exists.
422			if FirstMatchingMountain.is_none() {
423				FirstMatchingMountain = Some(format!("{}/{}", Base, Name));
424			}
425		}
426	}
427	FirstMatchingMountain
428}
429
430/// Get the app-data path prefix for aliasing (cached).
431pub fn AppDataPrefix() -> &'static Option<String> { APP_DATA_PREFIX.get_or_init(DetectAppDataPrefix) }
432
433/// Replace the long app-data path with `$APP` in a string.
434pub fn AliasPath(Input:&str) -> String {
435	if let Some(Prefix) = AppDataPrefix() {
436		Input.replace(Prefix.as_str(), "$APP")
437	} else {
438		Input.to_string()
439	}
440}
441
442// ── Dedup buffer ────────────────────────────────────────────────────────
443
444pub struct DedupState {
445	pub LastKey:String,
446	pub Count:u64,
447}
448
449pub static DEDUP:Mutex<DedupState> = Mutex::new(DedupState { LastKey:String::new(), Count:0 });
450
451/// Flush the dedup buffer - prints the pending count if > 1.
452pub fn FlushDedup() {
453	if let Ok(mut State) = DEDUP.lock() {
454		if State.Count > 1 {
455			let Tail = format!("  (x{})", State.Count);
456			eprintln!("{}", Tail);
457			WriteToFile(&Tail);
458		}
459		State.LastKey.clear();
460		State.Count = 0;
461	}
462}
463
464// ── Benign-probe classification (BATCH-17) ───────────────────────────────
465//
466// Extensions and the workbench probe dozens of optional files on every boot
467// (VS Code + Copilot + language-features). `stat ENOENT` lines for these
468// paths are functionally noise: they confirm the probe exists but nothing
469// acts on a failure. Three steps keep the log useful:
470//
471//   1. Known-optional patterns downgrade to Debug level (suppressed from the
472//      default dev-log stream, still written to the file sink).
473//   2. Per-unique-path dedup: log the first miss once per session via
474//      [`DebugOnce`]; later hits on the same path are swallowed.
475//   3. Virtual resource 404s (`vscode://`, cached globalStorage paths) are
476//      matched by the same helper so `BATCH-06`'s earlier suppression stays in
477//      one place.
478
479const BENIGN_ENOENT_SUBSTRINGS:&[&str] = &[
480	// VS Code / Claude / Copilot probe paths. Bare `/.claude` and `/.vscode`
481	// entries cover extension walk-ups that stat the directory itself before
482	// looking inside; the per-file variants below remain for self-documentation
483	// but are supersets of the bare directory match.
484	"/.claude",
485	"/.vscode",
486	".claude/agents",
487	".claude/settings.json",
488	".claude/settings.local.json",
489	".copilot/agents",
490	".github/copilot",
491	".github/agents",
492	".vscode/settings.json",
493	".vscode/launch.json",
494	".vscode/extensions.json",
495	".vscode/tasks.json",
496	".vscode/mcp.json",
497	".mcp.json",
498	"agentPlugins",
499	"agent-plugins",
500	"chatEditingSessions",
501	"chatSessions",
502	// Per-extension state probes.
503	"machineid",
504	"terminalSuggestGlobalsCacheV2.json",
505	"globalStorage",
506	// Optional user-level workbench config files. On fresh profiles these do
507	// not exist; the workbench probes them on every boot and creates on first
508	// write. These are the `$APP/User/<file>` forms emitted after
509	// `resolve_userdata`.
510	"/User/tasks.json",
511	"/User/mcp.json",
512	"/User/snippets",
513	"/User/keybindings.json",
514	// Workspace-storage sidecar files the workbench stats on every project
515	// open - absent until the AI-generated-workspace feature writes one.
516	"aiGeneratedWorkspaces.json",
517	// Git extension probes `.git/config` on every workspace folder to
518	// detect whether the folder is a git worktree; ENOENT is the
519	// normal "not a git repo" signal, not an error.
520	"/.git/config",
521	// Chat language-model registry is written on first chat interaction.
522	// Absent on fresh profiles; VS Code reads-before-first-write every boot.
523	"chatLanguageModels.json",
524	// VS Code writes per-profile configuration default overrides lazily; on
525	// a fresh profile the file does not yet exist and the workbench probes
526	// on every boot to see if it needs reading.
527	"configurationDefaultsOverrides",
528	// Chat images cache directory is lazy-created on first chat attachment.
529	"vscode-chat-images",
530	// Per-window output channel log files probed lazily by the workbench
531	// before first write. Path shape: `$APP/logs/<SESSION>/window<N>/output_<TIMESTAMP>`.
532	"/output_20",
533	// Per-window named log files VS Code stats on boot to detect crash-log
534	// rollovers. The `/<logsPath>/window<N>/<name>.log` layout means the
535	// window index can change, so match on filename alone - these names
536	// are stable VS Code conventions and never collide with real files.
537	"/network.log",
538	"/renderer.log",
539	"/views.log",
540	"/notebook.rendering.log",
541	// Virtual scheme misses already covered by earlier batches.
542	"vscode://schemas-associations/",
543	// First-run state files extensions create on demand.
544	"vscodevim.vim/.registers",
545	// Per-extension globalStorage subdirectories the workbench probes
546	// before the extension creates them (clangd, midudev.better-svg,
547	// muhammad-sammy.csharp, etc.).
548	"/User/globalStorage/",
549	// Per-window output channel + chat session state - lazy-created on
550	// first emit. Path shape:
551	// `<APP>/User/workspaceStorage/<wsId>/chatEditingSessions/<id>/state.json`.
552	"/chatEditingSessions/",
553	// User-level prompt and snippet folders - stock VS Code treats
554	// both as optional and lazily creates on first write. Already have
555	// `/User/snippets`; adding the prompt counterpart here.
556	"/User/prompts",
557	// Language-detection worker's seed file; absent on fresh profiles.
558	"languageDetectionWorkerCache.json",
559	// Editor-detection: VS Code's external-editor picker (and the vim
560	// + shell extensions) stat known editor app bundles to populate
561	// "Open With…" menus. Absent on a clean machine - expected.
562	"/Applications/Eclipse IDE.app",
563	"/Applications/Eclipse.app",
564	"/Applications/IntelliJ IDEA.app",
565	"/Applications/IntelliJ IDEA CE.app",
566	"/Applications/Sublime Text.app",
567	"/Applications/Notepad++.app",
568	"/Applications/Visual Studio Code.app",
569	"/Applications/Xcode.app",
570	// Vim migration path: vscodevim + others stat the user's Neovim /
571	// Vim config to offer an import wizard. Absent = "user has no
572	// existing Vim config", which is the default not an error.
573	"/.config/nvim/init.lua",
574	"/.config/nvim/init.vim",
575	"/.vimrc",
576	"/.gvimrc",
577	// SQLite state backing files workbench lazy-creates on first
578	// write. Present on subsequent runs; absent on first-ever launch
579	// of a profile.
580	"/state.vscdb",
581	"/state.vscdb-journal",
582	// Node module resolution often stats paths that don't exist as
583	// part of the resolution ladder. Already handled for
584	// `/User/globalStorage/`; add the companion workspaceStorage
585	// subtree too.
586	"/User/workspaceStorage/",
587	// gitlens probes `~/.land/globalStorage/<extId>/` before its first
588	// write, plus per-feature subdirs `gitlens/launchpad/`. Same lazy
589	// creation pattern as the other extension state files above; absent
590	// on every fresh profile boot.
591	"/globalStorage/eamodio.gitlens",
592	"/globalStorage/GitHub.copilot",
593	"/globalStorage/GitHub.copilot-chat",
594	"/globalStorage/Anthropic.claude-code",
595	"/globalStorage/RooVeterinaryInc.roo-cline",
596	// vim's per-mode register store: lazy-created on first Yank/Paste.
597	".registers",
598	// Sky / Output bundled `product.json` and friends are read by
599	// gitlens / copilot to detect host product metadata. Probed before
600	// they exist on first build.
601	"/Sky/Target/product.json",
602	"/Output/Target/product.json",
603];
604
605/// Return true when the given path is a known-optional probe whose absence
606/// is never an error condition. Used to downgrade `stat ENOENT` spam.
607pub fn IsBenignEnoent(Path:&str) -> bool { BENIGN_ENOENT_SUBSTRINGS.iter().any(|Needle| Path.contains(Needle)) }
608
609static DEBUG_ONCE_KEYS:OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
610
611fn DebugOnceKeys() -> &'static Mutex<std::collections::HashSet<String>> {
612	DEBUG_ONCE_KEYS.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
613}
614
615/// Emit the line exactly once per process, keyed on the supplied key. Later
616/// calls with the same key are silently dropped. The file sink still
617/// captures the very first occurrence so the probe is documented.
618pub fn DebugOnce(Tag:&str, Key:&str, Line:&str) {
619	if let Ok(mut Keys) = DebugOnceKeys().lock() {
620		if !Keys.insert(Key.to_string()) {
621			return;
622		}
623	}
624	if IsEnabled(Tag) || IsEnabled("all") {
625		let Formatted = format!("[DEV:{}] {}", Tag.to_uppercase(), Line);
626		eprintln!("{}", Formatted);
627		WriteToFile(&Formatted);
628	} else {
629		// Never echo to the console, but preserve in the file sink so
630		// post-mortems still see which probe paths fired.
631		let Formatted = format!("[DEV:{}/once] {}", Tag.to_uppercase(), Line);
632		WriteToFile(&Formatted);
633	}
634}
635
636// ── Tag resolution ──────────────────────────────────────────────────────
637
638fn EnabledTags() -> &'static Vec<String> {
639	ENABLED_TAGS.get_or_init(|| {
640		match std::env::var("Trace") {
641			Ok(Val) => Val.split(',').map(|S| S.trim().to_lowercase()).collect(),
642			Err(_) => vec![],
643		}
644	})
645}
646
647/// Whether `Trace=short` is active.
648pub fn IsShort() -> bool { *SHORT_MODE.get_or_init(|| EnabledTags().iter().any(|T| T == "short")) }
649
650/// Tags explicitly muted by `short` mode. These are the per-call
651/// firehose tags (receive / dispatch / verify / forward banners) that
652/// add no diagnostic signal beyond the aggregate IPC round-trip timing
653/// already present in the `ipc` / `grpc` summary lines. Listing them
654/// here keeps `short` usable as a "quiet but informative" default - the
655/// failure path for each of these still logs a specific error tag
656/// (`grpc` for gRPC errors, `ipc` for IPC failures, etc.).
657///
658/// Anything in this list is reachable only via `Trace=all`, an
659/// explicit tag match (e.g. `Trace=grpc-verbose`), or the
660/// tag-specific env override.
661const SHORT_MODE_MUTED_TAGS:&[&str] = &[
662	"grpc-verbose",
663	"vfs-verbose",
664	"fs-route",
665	"tauri-invoke",
666	"rpc-latency",
667	"tree-latency",
668	"nls",
669	"fs-read",
670	"preflight",
671	"wsns",
672	"storage-verbose",
673	"config-prime",
674	"cel-dispatch",
675	"output-verbose",
676	"command-register",
677	"provider-register",
678	"ext-scan-verbose",
679	"channel-stub",
680	"commands-verbose",
681	"scheme-assets",
682	"cocoon-stderr-verbose",
683	// `[DEV:VSCODE-API-GAP]` (emitted from Cocoon's WrapNamespaceWithHeuristics
684	// every time an extension reaches for a vscode.<ns>.<method> we haven't
685	// formally shimmed) is a per-method audit trail. Useful when actively
686	// auditing the API gap, noisy in default short-mode runs - mute by default;
687	// reachable via `Trace=all` or `Trace=vscode-api-gap`.
688	"vscode-api-gap",
689];
690
691/// Check if a tag is enabled.
692pub fn IsEnabled(Tag:&str) -> bool {
693	let Tags = EnabledTags();
694	if Tags.is_empty() {
695		return false;
696	}
697	let Lower = Tag.to_lowercase();
698	// Explicit tag match always wins (even if `short` would normally mute it).
699	if Tags.iter().any(|T| T == Lower.as_str()) {
700		return true;
701	}
702	if Tags.iter().any(|T| T == "all") {
703		return true;
704	}
705	if Tags.iter().any(|T| T == "short") {
706		return !SHORT_MODE_MUTED_TAGS.iter().any(|Muted| *Muted == Lower.as_str());
707	}
708	false
709}
710
711/// Log a tagged dev message. Only prints if the tag is enabled via
712/// Trace.
713///
714/// In `short` mode: aliases long paths, deduplicates consecutive identical
715/// lines.
716///
717/// **Release-mode behaviour:** the entire body is gated on
718/// `cfg!(debug_assertions)`. Production builds get zero logging by
719/// design - LLVM's dead-code-elimination removes the `format!`,
720/// `IsEnabled` lookup, file-sink write, and dedup-mutex lock so the
721/// hundreds of `dev_log!` callsites in the IPC hot path don't tax
722/// release builds at all. Type-checking still runs against the format
723/// arguments, which catches `{}` placeholder mismatches without
724/// imposing runtime cost.
725#[macro_export]
726macro_rules! dev_log {
727	($Tag:expr, $($Arg:tt)*) => {
728		if cfg!(debug_assertions) && $crate::IPC::DevLog::IsEnabled($Tag) {
729			let RawMessage = format!($($Arg)*);
730			let TagUpper = $Tag.to_uppercase();
731			if $crate::IPC::DevLog::IsShort() {
732				let Aliased = $crate::IPC::DevLog::AliasPath(&RawMessage);
733				let Key = format!("{}:{}", TagUpper, Aliased);
734				let ShouldPrint = {
735					if let Ok(mut State) = $crate::IPC::DevLog::DEDUP.lock() {
736						if State.LastKey == Key {
737							State.Count += 1;
738							false
739						} else {
740							let PrevCount = State.Count;
741							let HadPrev = !State.LastKey.is_empty();
742							State.LastKey = Key;
743							State.Count = 1;
744							if HadPrev && PrevCount > 1 {
745								let Tail = format!("  (x{})", PrevCount);
746								eprintln!("{}", Tail);
747								$crate::IPC::DevLog::WriteToFile(&Tail);
748							}
749							true
750						}
751					} else {
752						true
753					}
754				};
755				if ShouldPrint {
756					let Formatted = format!("[DEV:{}] {}", TagUpper, Aliased);
757					eprintln!("{}", Formatted);
758					$crate::IPC::DevLog::WriteToFile(&Formatted);
759				}
760			} else {
761				let Formatted = format!("[DEV:{}] {}", TagUpper, RawMessage);
762				eprintln!("{}", Formatted);
763				$crate::IPC::DevLog::WriteToFile(&Formatted);
764			}
765		}
766	};
767}
768
769// ============================================================================
770// OTLP Span Emission - sends spans directly to Jaeger/OTEL collector
771// ============================================================================
772
773static OTLP_AVAILABLE:AtomicBool = AtomicBool::new(true);
774static OTLP_TRACE_ID:OnceLock<String> = OnceLock::new();
775
776fn GetTraceId() -> &'static str {
777	OTLP_TRACE_ID.get_or_init(|| {
778		use std::{
779			collections::hash_map::DefaultHasher,
780			hash::{Hash, Hasher},
781		};
782		let mut H = DefaultHasher::new();
783		std::process::id().hash(&mut H);
784		SystemTime::now()
785			.duration_since(UNIX_EPOCH)
786			.unwrap_or_default()
787			.as_nanos()
788			.hash(&mut H);
789		format!("{:032x}", H.finish() as u128)
790	})
791}
792
793pub fn NowNano() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as u64 }
794
795/// Emit an OTLP span to the local collector (Jaeger at 127.0.0.1:4318).
796/// Fire-and-forget on a background thread. Stops trying after first failure.
797pub fn EmitOTLPSpan(Name:&str, StartNano:u64, EndNano:u64, Attributes:&[(&str, &str)]) {
798	if !cfg!(debug_assertions) {
799		return;
800	}
801	if !OTLP_AVAILABLE.load(Ordering::Relaxed) {
802		return;
803	}
804
805	let SpanId = format!("{:016x}", rand_u64());
806	let TraceId = GetTraceId().to_string();
807	let SpanName = Name.to_string();
808
809	let AttributesJson:Vec<String> = Attributes
810		.iter()
811		.map(|(K, V)| {
812			format!(
813				r#"{{"key":"{}","value":{{"stringValue":"{}"}}}}"#,
814				K,
815				V.replace('\\', "\\\\").replace('"', "\\\"")
816			)
817		})
818		.collect();
819
820	let IsError = SpanName.contains("error");
821
822	let StatusCode = if IsError { 2 } else { 1 };
823	let Payload = format!(
824		concat!(
825			r#"{{"resourceSpans":[{{"resource":{{"attributes":["#,
826			r#"{{"key":"service.name","value":{{"stringValue":"land-editor-mountain"}}}},"#,
827			r#"{{"key":"service.version","value":{{"stringValue":"0.0.1"}}}}"#,
828			r#"]}},"scopeSpans":[{{"scope":{{"name":"mountain.ipc","version":"1.0.0"}},"#,
829			r#""spans":[{{"traceId":"{}","spanId":"{}","name":"{}","kind":1,"#,
830			r#""startTimeUnixNano":"{}","endTimeUnixNano":"{}","#,
831			r#""attributes":[{}],"status":{{"code":{}}}}}]}}]}}]}}"#,
832		),
833		TraceId,
834		SpanId,
835		SpanName,
836		StartNano,
837		EndNano,
838		AttributesJson.join(","),
839		StatusCode,
840	);
841
842	// Fire-and-forget on a background thread
843	std::thread::spawn(move || {
844		use std::{
845			io::{Read as IoRead, Write as IoWrite},
846			net::TcpStream,
847			time::Duration,
848		};
849
850		let Ok(mut Stream) = TcpStream::connect_timeout(&"127.0.0.1:4318".parse().unwrap(), Duration::from_millis(200))
851		else {
852			OTLP_AVAILABLE.store(false, Ordering::Relaxed);
853			return;
854		};
855		let _ = Stream.set_write_timeout(Some(Duration::from_millis(200)));
856		let _ = Stream.set_read_timeout(Some(Duration::from_millis(200)));
857
858		let HttpReq = format!(
859			"POST /v1/traces HTTP/1.1\r\nHost: 127.0.0.1:4318\r\nContent-Type: application/json\r\nContent-Length: \
860			 {}\r\nConnection: close\r\n\r\n",
861			Payload.len()
862		);
863		if Stream.write_all(HttpReq.as_bytes()).is_err() {
864			return;
865		}
866		if Stream.write_all(Payload.as_bytes()).is_err() {
867			return;
868		}
869		let mut Buf = [0u8; 32];
870		let _ = Stream.read(&mut Buf);
871		if !(Buf.starts_with(b"HTTP/1.1 2") || Buf.starts_with(b"HTTP/1.0 2")) {
872			OTLP_AVAILABLE.store(false, Ordering::Relaxed);
873		}
874	});
875}
876
877fn rand_u64() -> u64 {
878	use std::{
879		collections::hash_map::DefaultHasher,
880		hash::{Hash, Hasher},
881	};
882	let mut H = DefaultHasher::new();
883	std::thread::current().id().hash(&mut H);
884	NowNano().hash(&mut H);
885	H.finish()
886}
887
888/// Convenience macro: emit an OTLP span for an IPC handler.
889/// Usage: `otel_span!("file:readFile", StartNano, &[("path", &SomePath)]);`
890#[macro_export]
891macro_rules! otel_span {
892	($Name:expr, $Start:expr, $Attrs:expr) => {
893		$crate::IPC::DevLog::EmitOTLPSpan($Name, $Start, $crate::IPC::DevLog::NowNano(), $Attrs)
894	};
895	($Name:expr, $Start:expr) => {
896		$crate::IPC::DevLog::EmitOTLPSpan($Name, $Start, $crate::IPC::DevLog::NowNano(), &[])
897	};
898}