Skip to main content

bacon_ls/
lib.rs

1//! Bacon Language Server
2use std::collections::{HashMap, HashSet};
3use std::env;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use argh::FromArgs;
9use bacon::Bacon;
10use flume::RecvError;
11use ls_types::{Diagnostic, DiagnosticSeverity, MessageType, ProgressToken, Range, Uri, WorkspaceFolder};
12use native::Cargo;
13use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
14use serde_json::{Map, Value};
15use shadow::ShadowWorkspace;
16use tokio::sync::{RwLock, RwLockWriteGuard};
17use tokio::task::JoinHandle;
18use tokio_util::sync::CancellationToken;
19use tower_lsp_server::{Client, LspService, Server, jsonrpc};
20use tracing_subscriber::fmt::format::FmtSpan;
21
22mod bacon;
23mod lsp;
24mod native;
25mod shadow;
26
27const PKG_NAME: &str = env!("CARGO_PKG_NAME");
28pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
29const LOCATIONS_FILE: &str = ".bacon-locations";
30const BACON_BACKGROUND_COMMAND: &str = "bacon";
31const BACON_BACKGROUND_COMMAND_ARGS: &str = "--headless -j bacon-ls";
32
33// Characters that must be percent-encoded when putting an OS path into a
34// `file://` URI. We keep `/` unencoded so it continues to split the path into
35// segments (clients expect multi-segment URIs). This covers the reserved URI
36// characters plus a few that break `Uri` parsing in practice (space, `#`,
37// `?`, `%`, `[`/`]`, backslash, etc.).
38const PATH_ENCODE_SET: &AsciiSet = &CONTROLS
39    .add(b' ')
40    .add(b'"')
41    .add(b'#')
42    .add(b'<')
43    .add(b'>')
44    .add(b'?')
45    .add(b'[')
46    .add(b'\\')
47    .add(b']')
48    .add(b'^')
49    .add(b'`')
50    .add(b'{')
51    .add(b'|')
52    .add(b'}')
53    .add(b'%');
54
55/// Build a `file://...` URI string from an OS path. Percent-encodes any
56/// characters that would otherwise break URI parsing (spaces, `#`, `?`, `%`,
57/// etc.), while leaving `/` intact so path segments survive.
58pub(crate) fn path_to_file_uri(path: &str) -> String {
59    format!("file://{}", utf8_percent_encode(path, PATH_ENCODE_SET))
60}
61
62/// Hash key for deduplicating diagnostics that share the same range, severity,
63/// and message. `DiagnosticSeverity` is `Eq` but not `Hash` in `ls-types`, so we
64/// project it down to a small integer tag.
65pub(crate) type DiagKey = (Range, i32, String);
66
67pub(crate) fn diag_key(d: &Diagnostic) -> DiagKey {
68    (d.range, severity_tag(d.severity), d.message.clone())
69}
70
71fn severity_tag(s: Option<DiagnosticSeverity>) -> i32 {
72    match s {
73        None => 0,
74        Some(s) if s == DiagnosticSeverity::ERROR => 1,
75        Some(s) if s == DiagnosticSeverity::WARNING => 2,
76        Some(s) if s == DiagnosticSeverity::INFORMATION => 3,
77        Some(s) if s == DiagnosticSeverity::HINT => 4,
78        Some(_) => -1,
79    }
80}
81
82/// bacon-ls - https://github.com/crisidev/bacon-ls
83#[derive(Debug, FromArgs)]
84pub struct Args {
85    /// display version information
86    #[argh(switch, short = 'v')]
87    pub version: bool,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91enum BackendChoice {
92    Cargo,
93    Bacon,
94}
95
96#[derive(Debug)]
97enum BackendRuntime {
98    Bacon {
99        config: BaconOptions,
100        runtime: BaconRuntime,
101    },
102    Cargo {
103        config: CargoOptions,
104        runtime: CargoRuntime,
105    },
106}
107
108impl BackendRuntime {
109    fn backend_choice(&self) -> BackendChoice {
110        match self {
111            Self::Bacon { .. } => BackendChoice::Bacon,
112            Self::Cargo { .. } => BackendChoice::Cargo,
113        }
114    }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub(crate) enum CargoRunState {
119    Idle,
120    Running,
121    RunningPending,
122}
123
124#[derive(Debug, Copy, Clone)]
125pub(crate) enum PublishMode {
126    CancelRunning,
127    QueueIfRunning,
128}
129
130#[derive(Debug)]
131pub(crate) struct CargoOptions {
132    // "check" or "clippy"
133    pub(crate) command: String,
134    pub(crate) features: Vec<String>,
135    // `-p crate_name`
136    pub(crate) package: Option<String>,
137    // Extra arguments which do not have a nice wrapper
138    pub(crate) extra_command_args: Vec<String>,
139    pub(crate) env: Vec<(String, String)>,
140    pub(crate) publish_mode: PublishMode,
141    // Interval at which we refresh (send) cargo diagnostics we have so far
142    // None means wait until the cargo command is fully done
143    pub(crate) refresh_interval_seconds: Option<Duration>,
144    /// User override: when `Some(true)`, always emit children as separate
145    /// diagnostics instead of related information, regardless of client
146    /// capability. When `None`, follow the client advertisement.
147    pub(crate) separate_child_diagnostics: Option<bool>,
148    pub(crate) check_on_save: bool,
149    pub(crate) clear_diagnostics_on_check: bool,
150    /// Live-as-you-type diagnostics. When true, the server mirrors the
151    /// workspace into a hardlinked shadow under
152    /// `target/bacon-ls-live/shadow/`, replaces dirty buffers in the shadow
153    /// on `did_change`, and runs cargo against the shadow with a separate
154    /// target dir. Off by default.
155    pub(crate) update_on_insert: bool,
156    /// Quiet period after the most recent `did_change` before the live
157    /// cargo run is triggered. Coalesces bursts of keystrokes into a single
158    /// run.
159    pub(crate) update_on_insert_debounce: Duration,
160}
161
162impl CargoOptions {
163    pub(crate) fn build_command_args(&self) -> Vec<String> {
164        let mut args = vec![self.command.clone()];
165        args.push("--message-format=json-diagnostic-rendered-ansi".to_string());
166
167        if !self.features.is_empty() {
168            args.push("--features".to_string());
169            let mut features = String::new();
170            for feature in &self.features[..self.features.len() - 1] {
171                features += feature;
172                features += ",";
173            }
174            features += &self.features[self.features.len() - 1];
175            args.push(features);
176        }
177
178        if let Some(pkg) = self.package.clone() {
179            args.push("-p".to_string());
180            args.push(pkg);
181        }
182
183        for arg in self.extra_command_args.iter().cloned() {
184            args.push(arg);
185        }
186
187        args
188    }
189
190    pub(crate) fn update_from_json_obj(&mut self, cargo_obj: &Map<String, Value>) -> jsonrpc::Result<()> {
191        if let Some(value) = cargo_obj.get("command") {
192            self.command = value
193                .as_str()
194                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
195                .to_string();
196        }
197
198        if let Some(value) = cargo_obj.get("features") {
199            self.features = value
200                .as_array()
201                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
202                .iter()
203                .map(|item| {
204                    item.as_str()
205                        .map(|s| s.to_string())
206                        .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))
207                })
208                .collect::<jsonrpc::Result<Vec<_>>>()?;
209        }
210
211        if let Some(value) = cargo_obj.get("package") {
212            self.package = Some(
213                value
214                    .as_str()
215                    .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
216                    .to_string(),
217            );
218        }
219
220        if let Some(value) = cargo_obj.get("extraArgs") {
221            self.extra_command_args = value
222                .as_array()
223                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
224                .iter()
225                .map(|item| {
226                    item.as_str()
227                        .map(|s| s.to_string())
228                        .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))
229                })
230                .collect::<jsonrpc::Result<Vec<_>>>()?;
231        }
232
233        if let Some(value) = cargo_obj.get("env") {
234            self.env = value
235                .as_object()
236                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
237                .iter()
238                .map(|(k, v)| {
239                    let val = v
240                        .as_str()
241                        .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
242                    Ok((k.clone(), val.to_string()))
243                })
244                .collect::<jsonrpc::Result<Vec<_>>>()?;
245        }
246
247        if let Some(value) = cargo_obj.get("cancelRunning") {
248            let cancel = value
249                .as_bool()
250                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
251            self.publish_mode = if cancel {
252                PublishMode::CancelRunning
253            } else {
254                PublishMode::QueueIfRunning
255            };
256        }
257
258        if let Some(value) = cargo_obj.get("refreshIntervalSeconds") {
259            if value.is_null() {
260                self.refresh_interval_seconds = None;
261            } else {
262                let seconds = value
263                    .as_i64()
264                    .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
265                if seconds < 0 {
266                    self.refresh_interval_seconds = None;
267                } else {
268                    self.refresh_interval_seconds = Some(Duration::from_secs(seconds as u64));
269                }
270            }
271        }
272
273        if let Some(value) = cargo_obj.get("separateChildDiagnostics") {
274            self.separate_child_diagnostics = value.as_bool();
275        }
276
277        if let Some(value) = cargo_obj.get("checkOnSave") {
278            self.check_on_save = value
279                .as_bool()
280                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
281        }
282
283        if let Some(value) = cargo_obj.get("clearDiagnosticsOnCheck") {
284            self.clear_diagnostics_on_check = value
285                .as_bool()
286                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
287        }
288
289        if let Some(value) = cargo_obj.get("updateOnInsertDebounceMillis") {
290            let millis = value
291                .as_u64()
292                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
293            self.update_on_insert_debounce = Duration::from_millis(millis);
294        }
295
296        Ok(())
297    }
298
299    pub(crate) fn reset(&mut self) {
300        *self = Self::default();
301    }
302}
303
304impl Default for CargoOptions {
305    fn default() -> Self {
306        Self {
307            env: Vec::new(),
308            publish_mode: PublishMode::CancelRunning,
309            command: "check".to_string(),
310            features: vec![],
311            extra_command_args: vec![],
312            package: None,
313            refresh_interval_seconds: Some(Duration::from_secs(1)),
314            separate_child_diagnostics: None,
315            check_on_save: true,
316            clear_diagnostics_on_check: false,
317            update_on_insert: false,
318            update_on_insert_debounce: Duration::from_millis(500),
319        }
320    }
321}
322
323#[derive(Debug)]
324pub(crate) struct BaconOptions {
325    pub(crate) locations_file: String,
326    pub(crate) run_in_background: bool,
327    pub(crate) run_in_background_command: String,
328    pub(crate) run_in_background_command_args: String,
329    pub(crate) validate_preferences: bool,
330    pub(crate) create_preferences_file: bool,
331    pub(crate) synchronize_all_open_files_wait: Duration,
332    pub(crate) update_on_save: bool,
333    pub(crate) update_on_save_wait: Duration,
334}
335
336impl BaconOptions {
337    pub(crate) fn update_from_json_obj(&mut self, bacon_obj: &Map<String, Value>) -> jsonrpc::Result<()> {
338        if let Some(value) = bacon_obj.get("locationsFile") {
339            self.locations_file = value
340                .as_str()
341                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
342                .to_string();
343        }
344        if let Some(value) = bacon_obj.get("runInBackground") {
345            self.run_in_background = value
346                .as_bool()
347                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
348        }
349        if let Some(value) = bacon_obj.get("runInBackgroundCommand") {
350            self.run_in_background_command = value
351                .as_str()
352                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
353                .to_string();
354        }
355        if let Some(value) = bacon_obj.get("runInBackgroundCommandArguments") {
356            self.run_in_background_command_args = value
357                .as_str()
358                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
359                .to_string();
360        }
361        if let Some(value) = bacon_obj.get("validatePreferences") {
362            self.validate_preferences = value
363                .as_bool()
364                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
365        }
366        if let Some(value) = bacon_obj.get("createPreferencesFile") {
367            self.create_preferences_file = value
368                .as_bool()
369                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
370        }
371        if let Some(value) = bacon_obj.get("synchronizeAllOpenFilesWaitMillis") {
372            self.synchronize_all_open_files_wait = Duration::from_millis(
373                value
374                    .as_u64()
375                    .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?,
376            );
377        }
378        if let Some(value) = bacon_obj.get("updateOnSave") {
379            self.update_on_save = value
380                .as_bool()
381                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
382        }
383        if let Some(value) = bacon_obj.get("updateOnSaveWaitMillis") {
384            self.update_on_save_wait = Duration::from_millis(
385                value
386                    .as_u64()
387                    .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?,
388            );
389        }
390
391        Ok(())
392    }
393
394    pub fn reset(&mut self) {
395        *self = Self::default();
396    }
397}
398
399impl Default for BaconOptions {
400    fn default() -> Self {
401        Self {
402            locations_file: LOCATIONS_FILE.to_string(),
403            run_in_background: true,
404            run_in_background_command: BACON_BACKGROUND_COMMAND.to_string(),
405            run_in_background_command_args: BACON_BACKGROUND_COMMAND_ARGS.to_string(),
406            validate_preferences: true,
407            create_preferences_file: true,
408            synchronize_all_open_files_wait: Duration::from_millis(2000),
409            update_on_save: true,
410            update_on_save_wait: Duration::from_millis(1000),
411        }
412    }
413}
414
415/// Per-invocation overrides used to redirect a cargo run from the real
416/// workspace into the hardlinked shadow workspace for live diagnostics.
417#[derive(Debug)]
418pub(crate) struct LiveCheckContext {
419    pub(crate) shadow_root: PathBuf,
420    pub(crate) shadow_target_dir: PathBuf,
421    pub(crate) real_root: PathBuf,
422}
423
424#[derive(Debug)]
425pub(crate) struct CargoRuntime {
426    cancel_token: CancellationToken,
427    run_state: CargoRunState,
428    files_with_diags: HashSet<Uri>,
429    diagnostics_version: i32,
430    build_folder: PathBuf,
431    // Timestamp of the most recent publish_cargo_diagnostics invocation.
432    // Used by did_open to avoid kicking off a redundant run when one was
433    // just triggered (e.g. the initial run from `initialized` immediately
434    // followed by the client's first `didOpen`).
435    last_run_started: Option<Instant>,
436    /// Hardlinked shadow of the workspace used for live "as you type"
437    /// diagnostics. None until the first did_change with `update_on_insert`
438    /// enabled — building it eagerly at backend init would block startup on
439    /// large workspaces for users who never trigger live mode.
440    pub(crate) shadow: Option<ShadowWorkspace>,
441    /// File URIs that currently have a dirty buffer overlaid in the shadow.
442    /// On did_save / did_close we restore each entry to a hardlink so the
443    /// next live run reads the on-disk version.
444    pub(crate) dirty_files: HashSet<Uri>,
445    /// Pending debounced live-cargo trigger. Each `did_change` cancels the
446    /// prior handle and schedules a new one so only the last keystroke fires
447    /// a check.
448    pub(crate) live_debounce: Option<JoinHandle<()>>,
449}
450
451impl Default for CargoRuntime {
452    fn default() -> Self {
453        Self {
454            cancel_token: CancellationToken::new(),
455            run_state: CargoRunState::Idle,
456            files_with_diags: HashSet::new(),
457            diagnostics_version: 0,
458            build_folder: PathBuf::new(),
459            last_run_started: None,
460            shadow: None,
461            dirty_files: HashSet::new(),
462            live_debounce: None,
463        }
464    }
465}
466
467#[derive(Debug)]
468pub(crate) struct BaconRuntime {
469    pub(crate) shutdown_token: CancellationToken,
470    pub(crate) open_files: HashSet<Uri>,
471    // Some(..) if we have to run bacon in the background ourselves
472    pub(crate) command_handle: Option<JoinHandle<()>>,
473    pub(crate) sync_files_handle: JoinHandle<()>,
474    // Monotonic counter stamped onto each publishDiagnostics call so clients
475    // can discard stale results if publishes arrive out of order.
476    pub(crate) diagnostics_version: i32,
477}
478
479#[derive(Debug, Default)]
480struct State {
481    project_root: Option<PathBuf>,
482    workspace_folders: Option<Vec<WorkspaceFolder>>,
483    diagnostics_data_supported: bool,
484    related_information_supported: bool,
485    backend: Option<BackendRuntime>,
486    /// Set by `initialize()` from `initialization_options.cargo.updateOnInsert`.
487    /// We need this at initialize-time to advertise a `Full` text-document
488    /// sync capability, because dynamic `client/registerCapability` for
489    /// `textDocument/didChange` after `initialized` doesn't reliably retrofit
490    /// already-attached buffers (Neovim, in particular, ignores it). A
491    /// statically-advertised capability is honored at attach.
492    init_update_on_insert: bool,
493}
494
495#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
496pub(crate) struct CorrectionEdit {
497    pub(crate) range: Range,
498    pub(crate) new_text: String,
499}
500
501// A single logical fix can require several disjoint byte-range edits. For
502// example, removing `Compact` from `use …::{Compact, FmtSpan}` produces three
503// edits: remove `{`, remove `Compact, `, remove `}`, leaving `use …::FmtSpan`.
504// All edits must be applied atomically so the file stays valid.
505#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
506pub(crate) struct Correction {
507    pub(crate) label: String,
508    pub(crate) edits: Vec<CorrectionEdit>,
509}
510
511impl Correction {
512    pub(crate) fn from_single(range: Range, new_text: &str) -> Self {
513        let label = if new_text.is_empty() {
514            "Remove".to_string()
515        } else {
516            format!("Replace with: {new_text}")
517        };
518        Self {
519            label,
520            edits: vec![CorrectionEdit {
521                range,
522                new_text: new_text.to_string(),
523            }],
524        }
525    }
526
527    pub(crate) fn from_multi(edits: Vec<CorrectionEdit>) -> Self {
528        let label = match edits.iter().find(|e| !e.new_text.is_empty()) {
529            None => "Remove".to_string(),
530            Some(e) => format!("Replace with: {}", e.new_text),
531        };
532        Self { label, edits }
533    }
534}
535
536#[derive(Debug, serde::Serialize, serde::Deserialize)]
537struct DiagnosticData {
538    corrections: Vec<Correction>,
539}
540
541#[derive(Debug, Clone)]
542pub struct BaconLs {
543    client: Arc<Client>,
544    state: Arc<RwLock<State>>,
545}
546
547impl BaconLs {
548    fn new(client: Client) -> Self {
549        Self {
550            client: Arc::new(client),
551            state: Arc::new(RwLock::new(State::default())),
552        }
553    }
554
555    fn configure_tracing(log_level: Option<String>, log_path: Option<&Path>) {
556        // Configure logging to file.
557        let level = log_level.unwrap_or_else(|| env::var("RUST_LOG").unwrap_or("off".to_string()));
558        if level == "off" {
559            return;
560        }
561        let default_path = PathBuf::from(format!("{PKG_NAME}.log"));
562        let log_path = log_path.unwrap_or(&default_path);
563        let file = match std::fs::OpenOptions::new()
564            .create(true)
565            .write(true)
566            .truncate(true)
567            .open(log_path)
568        {
569            Ok(file) => file,
570            Err(e) => {
571                // stdin/stdout are the LSP jsonrpc pipes; stderr is usually
572                // captured by the client's trace window. One line there is the
573                // best we can do to tell the user why logging is silent.
574                eprintln!(
575                    "{PKG_NAME}: could not open log file {}: {e} (tracing disabled)",
576                    log_path.display()
577                );
578                return;
579            }
580        };
581        // try_init: tests may install the subscriber more than once across the
582        // process lifetime (cargo runs them in a single binary). Don't panic
583        // if a global subscriber is already set — the first one wins.
584        let _ = tracing_subscriber::fmt()
585            .with_env_filter(level)
586            .with_writer(file)
587            .with_thread_names(true)
588            .with_span_events(FmtSpan::CLOSE)
589            .with_target(true)
590            .with_file(true)
591            .with_line_number(true)
592            .try_init();
593    }
594
595    /// Run the LSP server.
596    pub async fn serve() {
597        Self::configure_tracing(None, None);
598        // Lock stdin / stdout.
599        let stdin = tokio::io::stdin();
600        let stdout = tokio::io::stdout();
601        // Start the service.
602        let (service, socket) = LspService::new(Self::new);
603        Server::new(stdin, stdout, socket).serve(service).await;
604        // Force the process to terminate instead of waiting for the tokio
605        // runtime to drain. Some background tasks (bacon subprocess readers,
606        // file watchers) can linger past the `exit` notification; if the
607        // process doesn't die promptly, `:LspRestart` in Neovim gives up
608        // before starting a fresh instance.
609        std::process::exit(0);
610    }
611
612    async fn find_git_root_directory(path: &Path) -> Option<PathBuf> {
613        let output = tokio::process::Command::new("git")
614            .arg("-C")
615            .arg(path)
616            .arg("rev-parse")
617            .arg("--show-toplevel")
618            .output()
619            .await
620            .ok()?;
621
622        if output.status.success() {
623            String::from_utf8(output.stdout).ok().map(|v| PathBuf::from(v.trim()))
624        } else {
625            None
626        }
627    }
628
629    fn detect_backend(values: &Map<String, Value>) -> Result<BackendChoice, String> {
630        if let Some(value) = values.get("backend") {
631            let backend = value.as_str().ok_or("'backend' must be a string")?;
632            match backend {
633                "cargo" => Ok(BackendChoice::Cargo),
634                "bacon" => Ok(BackendChoice::Bacon),
635                other => Err(format!("Invalid backend value '{other}'. Must be 'cargo' or 'bacon'.")),
636            }
637        } else {
638            let has_cargo = values.get("cargo").and_then(|v| v.as_object()).is_some();
639            let has_bacon = values.get("bacon").and_then(|v| v.as_object()).is_some();
640            match (has_cargo, has_bacon) {
641                (true, true) => Err(
642                    "Both 'cargo' and 'bacon' config sections present without a 'backend' key. \
643                     Set 'backend' to 'cargo' or 'bacon'."
644                        .to_string(),
645                ),
646                (_, true) => Ok(BackendChoice::Bacon),
647                _ => Ok(BackendChoice::Cargo),
648            }
649        }
650    }
651
652    async fn pull_configuration(&self) {
653        tracing::debug!("pull_configuration");
654
655        let configuration_fut = self.client.configuration(vec![ls_types::ConfigurationItem {
656            scope_uri: None,
657            section: Some("bacon_ls".to_string()),
658        }]);
659        // A client that never answers `workspace/configuration` (e.g. one
660        // mid-teardown) would otherwise keep this await alive forever, which
661        // in turn pins the `initialized` future inside the server loop and
662        // blocks a clean shutdown.
663        let response = match tokio::time::timeout(std::time::Duration::from_secs(5), configuration_fut).await {
664            Ok(Ok(response)) => response,
665            Ok(Err(e)) => {
666                tracing::error!("failed to pull configuration: {e}");
667                return;
668            }
669            Err(_) => {
670                tracing::warn!("workspace/configuration request timed out; proceeding with defaults");
671                return;
672            }
673        };
674
675        let Some(settings) = response.into_iter().next() else {
676            tracing::warn!("empty configuration response from client");
677            return;
678        };
679
680        tracing::trace!("pulled configuration: {settings:#?}");
681        self.adapt_to_settings(&settings).await;
682    }
683
684    async fn adapt_to_settings(&self, settings: &Value) {
685        let mut state = self.state.write().await;
686        let Some(values) = settings.as_object() else {
687            tracing::warn!("configuration is not a JSON object");
688            return;
689        };
690
691        if state.backend.is_none() {
692            let backend_choice = match Self::detect_backend(values) {
693                Ok(choice) => {
694                    tracing::info!(backend = ?choice, "backend detected");
695                    choice
696                }
697                Err(msg) => {
698                    tracing::error!("{msg}");
699                    self.client.show_message(MessageType::ERROR, &msg).await;
700                    return;
701                }
702            };
703
704            match backend_choice {
705                BackendChoice::Bacon => {
706                    let mut config = BaconOptions::default();
707                    if let Some(bacon_obj) = values.get("bacon").and_then(|v| v.as_object())
708                        && let Err(e) = config.update_from_json_obj(bacon_obj)
709                    {
710                        tracing::error!("invalid bacon configuration: {e}");
711                        self.client
712                            .show_message(MessageType::ERROR, format!("Error in \"bacon\" section: {e}"))
713                            .await;
714                    }
715
716                    if config.validate_preferences {
717                        if let Err(e) = Bacon::validate_preferences(
718                            &config.run_in_background_command,
719                            config.create_preferences_file,
720                        )
721                        .await
722                        {
723                            tracing::error!("{e}");
724                            self.client.show_message(MessageType::ERROR, e).await;
725                        }
726                    } else {
727                        tracing::warn!("skipping validation of bacon preferences, validateBaconPreferences is false");
728                    }
729
730                    let proj_root = state.project_root.clone();
731                    let shutdown_token = CancellationToken::new();
732                    let command_handle = if config.run_in_background {
733                        let mut current_dir = None;
734                        if let Ok(cwd) = env::current_dir() {
735                            current_dir = Self::find_git_root_directory(&cwd).await;
736                            if let Some(dir) = &current_dir {
737                                if !dir.join("Cargo.toml").exists() {
738                                    current_dir = proj_root;
739                                }
740                            } else {
741                                current_dir = proj_root;
742                            }
743                        }
744
745                        match Bacon::run_in_background(
746                            &config.run_in_background_command,
747                            &config.run_in_background_command_args,
748                            current_dir.as_ref(),
749                            shutdown_token.clone(),
750                        )
751                        .await
752                        {
753                            Ok(command) => {
754                                tracing::info!("bacon was started successfully and is running in the background");
755                                Some(command)
756                            }
757                            Err(e) => {
758                                tracing::error!("{e}");
759                                self.client.show_message(MessageType::ERROR, e).await;
760                                None
761                            }
762                        }
763                    } else {
764                        tracing::warn!("skipping background bacon startup, runBaconInBackground is false");
765                        None
766                    };
767
768                    let task_state = self.state.clone();
769                    let task_client = self.client.clone();
770                    state.backend = Some(BackendRuntime::Bacon {
771                        config,
772                        runtime: BaconRuntime {
773                            shutdown_token,
774                            open_files: HashSet::new(),
775                            command_handle,
776                            sync_files_handle: tokio::task::spawn(Self::synchronize_diagnostics(
777                                task_state,
778                                task_client,
779                            )),
780                            diagnostics_version: 0,
781                        },
782                    });
783                    tracing::info!("bacon backend initialized");
784                }
785                BackendChoice::Cargo => {
786                    let mut config = CargoOptions::default();
787                    // `update_on_insert` is sourced exclusively from
788                    // `initialization_options.cargo.updateOnInsert` (read in
789                    // `initialize` and stashed on `State`). The static
790                    // `textDocument/didChange` capability has to be decided
791                    // before workspace settings even arrive, so the runtime
792                    // gate has to come from the same place.
793                    if state.init_update_on_insert {
794                        config.update_on_insert = true;
795                    }
796                    if let Some(cargo_obj) = values.get("cargo").and_then(|v| v.as_object())
797                        && let Err(e) = config.update_from_json_obj(cargo_obj)
798                    {
799                        tracing::error!("invalid cargo configuration: {e}");
800                        self.client
801                            .show_message(MessageType::ERROR, format!("Error in \"cargo\" section: {e}"))
802                            .await;
803                    }
804                    if let Err(e) = Self::init_cargo_backend(&mut state, config) {
805                        tracing::error!("{e}");
806                        drop(state);
807                        self.client.show_message(MessageType::ERROR, e).await;
808                        return;
809                    }
810                    drop(state);
811                }
812            }
813        } else {
814            let current_choice = match &state.backend {
815                Some(BackendRuntime::Bacon { .. }) => BackendChoice::Bacon,
816                Some(BackendRuntime::Cargo { .. }) => BackendChoice::Cargo,
817                None => unreachable!("backend is Some in this branch"),
818            };
819            let desired = match Self::detect_backend(values) {
820                Ok(choice) => choice,
821                Err(err) => {
822                    tracing::error!("invalid backend configuration on reload: {err}");
823                    self.client.show_message(MessageType::ERROR, &err).await;
824                    return;
825                }
826            };
827
828            if desired != current_choice {
829                let msg = "Backend cannot be changed while the server is running. \
830                           Restart the server to switch backends.";
831                tracing::error!("{msg}");
832                self.client.show_message(MessageType::ERROR, msg).await;
833                return;
834            }
835
836            let project_root = state.project_root.clone();
837            let init_update_on_insert = state.init_update_on_insert;
838            match &mut state.backend {
839                Some(BackendRuntime::Cargo { config, runtime }) => {
840                    config.reset();
841                    if init_update_on_insert {
842                        config.update_on_insert = true;
843                    }
844                    if let Some(cargo_obj) = values.get("cargo").and_then(|v| v.as_object())
845                        && let Err(e) = config.update_from_json_obj(cargo_obj)
846                    {
847                        tracing::error!("invalid cargo configuration: {e}");
848                        self.client
849                            .show_message(MessageType::ERROR, format!("Error in \"cargo\" section: {e}"))
850                            .await;
851                    }
852                    if let Some(root) = project_root {
853                        runtime.build_folder = root;
854                    }
855                    tracing::debug!("cargo configuration updated");
856                }
857                Some(BackendRuntime::Bacon { config, .. }) => {
858                    config.reset();
859                    if let Some(bacon_obj) = values.get("bacon").and_then(|v| v.as_object())
860                        && let Err(e) = config.update_from_json_obj(bacon_obj)
861                    {
862                        tracing::error!("invalid bacon configuration: {e}");
863                        self.client
864                            .show_message(MessageType::ERROR, format!("Error in \"bacon\" section: {e}"))
865                            .await;
866                    }
867                    tracing::debug!("bacon configuration updated");
868                }
869                None => unreachable!("backend is Some in this branch"),
870            }
871        }
872    }
873
874    fn init_cargo_backend(state: &mut RwLockWriteGuard<'_, State>, config: CargoOptions) -> Result<(), String> {
875        let build_folder = match &state.project_root {
876            Some(root) => root.clone(),
877            None => match env::current_dir() {
878                Ok(cwd) => {
879                    tracing::warn!(
880                        "no Cargo project root detected; falling back to current working directory: {}",
881                        cwd.display()
882                    );
883                    cwd
884                }
885                Err(e) => {
886                    return Err(format!(
887                        "cargo backend cannot start: no project root detected and current working \
888                         directory is unavailable ({e}). Open a folder containing a Cargo.toml and \
889                         restart the server."
890                    ));
891                }
892            },
893        };
894        let runtime = CargoRuntime {
895            build_folder,
896            ..CargoRuntime::default()
897        };
898        tracing::info!(build_folder = ?runtime.build_folder, "cargo backend initialized");
899        state.backend = Some(BackendRuntime::Cargo { config, runtime });
900        Ok(())
901    }
902
903    /// Trigger a save-time cargo run against the real workspace.
904    async fn publish_cargo_diagnostics(&self) {
905        self.publish_cargo_diagnostics_inner(None).await;
906    }
907
908    /// Trigger a live "as you type" cargo run against the hardlinked shadow
909    /// workspace. Builds the shadow on first call. Returns silently if
910    /// `update_on_insert` isn't on or the shadow can't be built.
911    pub(crate) async fn publish_cargo_diagnostics_live(&self) {
912        let live_on = {
913            let state = self.state.read().await;
914            matches!(
915                &state.backend,
916                Some(BackendRuntime::Cargo { config, .. }) if config.update_on_insert
917            )
918        };
919        if !live_on {
920            return;
921        }
922        let Some(shadow) = self.ensure_shadow_built().await else {
923            return;
924        };
925        let ctx = LiveCheckContext {
926            shadow_root: shadow.shadow_root().to_path_buf(),
927            shadow_target_dir: shadow.target_dir().to_path_buf(),
928            real_root: shadow.real_root().to_path_buf(),
929        };
930        self.publish_cargo_diagnostics_inner(Some(&ctx)).await;
931    }
932
933    async fn publish_cargo_diagnostics_inner(&self, live: Option<&LiveCheckContext>) {
934        tracing::info!(live = live.is_some(), "starting cargo diagnostics run");
935        let mut guard = self.state.write().await;
936        let project_root = guard.project_root.clone();
937        let related_information_supported = guard.related_information_supported;
938
939        let Some(BackendRuntime::Cargo { config, runtime }) = &mut guard.backend else {
940            return;
941        };
942        let use_related_information = !config
943            .separate_child_diagnostics
944            .unwrap_or(!related_information_supported);
945        let cargo_command = config.command.clone();
946        let mut cargo_env = config.env.clone();
947        let mut cmd_args = config.build_command_args();
948        let publish_mode = config.publish_mode;
949        let clear_diagnostics_on_check = config.clear_diagnostics_on_check;
950        let build_folder = match live {
951            Some(ctx) => {
952                cmd_args.push(format!("--target-dir={}", ctx.shadow_target_dir.display()));
953                // `--remap-path-prefix` makes rustc emit diagnostic spans with
954                // the real workspace path in place of the shadow path, so the
955                // editor opens the user's source file instead of a target/ copy.
956                let rustflags = format!(
957                    "--remap-path-prefix={}={}",
958                    ctx.shadow_root.display(),
959                    ctx.real_root.display()
960                );
961                // Honor any RUSTFLAGS the user already set in their config.
962                if let Some(slot) = cargo_env.iter_mut().find(|(k, _)| k == "RUSTFLAGS") {
963                    slot.1.push(' ');
964                    slot.1.push_str(&rustflags);
965                } else {
966                    cargo_env.push(("RUSTFLAGS".to_string(), rustflags));
967                }
968                ctx.shadow_root.clone()
969            }
970            None => runtime.build_folder.clone(),
971        };
972        runtime.diagnostics_version = runtime.diagnostics_version.wrapping_add(1);
973        runtime.last_run_started = Some(Instant::now());
974        let version = runtime.diagnostics_version;
975        let refresh_interval = config.refresh_interval_seconds;
976
977        let cancel_token = match publish_mode {
978            PublishMode::CancelRunning => {
979                runtime.cancel_token.cancel();
980                runtime.cancel_token = CancellationToken::new();
981                runtime.cancel_token.clone()
982            }
983            PublishMode::QueueIfRunning => match runtime.run_state {
984                CargoRunState::Running | CargoRunState::RunningPending => {
985                    runtime.run_state = CargoRunState::RunningPending;
986                    tracing::debug!("cargo already running, marking pending");
987                    drop(guard);
988                    return;
989                }
990                CargoRunState::Idle => {
991                    runtime.run_state = CargoRunState::Running;
992                    runtime.cancel_token.clone()
993                }
994            },
995        };
996
997        // Drain the URIs we need to clear into a local Vec, then drop the
998        // state lock BEFORE doing any LSP IO. Holding the write guard across
999        // awaited publishes blocks every other handler (did_open, did_close,
1000        // codeAction, …) for the duration of the round-trips.
1001        let files_to_clear: Vec<Uri> = if clear_diagnostics_on_check {
1002            runtime.files_with_diags.drain().collect()
1003        } else {
1004            Vec::new()
1005        };
1006
1007        drop(guard);
1008
1009        for file in files_to_clear {
1010            self.client.publish_diagnostics(file, vec![], Some(version)).await;
1011        }
1012
1013        let token = ProgressToken::Number(version);
1014        let progress = self
1015            .client
1016            .progress(token, "checking")
1017            .with_message(format!("cargo {cargo_command}"))
1018            .with_percentage(0)
1019            .begin()
1020            .await;
1021
1022        let (tx, rx) = flume::unbounded();
1023
1024        let cargo_future = Cargo::cargo_diagnostics(
1025            cmd_args,
1026            &cargo_env,
1027            project_root.as_ref(),
1028            &build_folder,
1029            use_related_information,
1030            &progress,
1031            tx,
1032        );
1033
1034        let consumer_client = self.client.clone();
1035        let diagnostic_consumer = async move {
1036            // Per-URI bucket: the diagnostics to publish, a `seen` set keyed by
1037            // (range, severity, message) for O(1) dedup, and a dirty flag for
1038            // partial publishes during the cargo run.
1039            let mut diagnostics_map = HashMap::<Uri, (Vec<Diagnostic>, HashSet<DiagKey>, bool)>::new();
1040
1041            enum AccumulateResult {
1042                Closed,
1043                NewDiagnostic,
1044                Duplicate,
1045            }
1046
1047            fn accumulate_diagnostics(
1048                recv_result: Result<(Uri, Diagnostic), RecvError>,
1049                diagnostics_map: &mut HashMap<Uri, (Vec<Diagnostic>, HashSet<DiagKey>, bool)>,
1050            ) -> AccumulateResult {
1051                let Ok((url, diagnostic)) = recv_result else {
1052                    return AccumulateResult::Closed;
1053                };
1054                let (diagnostics, seen, dirty) = diagnostics_map.entry(url).or_default();
1055                if seen.insert(diag_key(&diagnostic)) {
1056                    diagnostics.push(diagnostic);
1057                    *dirty = true;
1058                    AccumulateResult::NewDiagnostic
1059                } else {
1060                    AccumulateResult::Duplicate
1061                }
1062            }
1063
1064            if let Some(refresh_interval) = refresh_interval {
1065                // The very first diagnostic of a run is published immediately
1066                // — waiting up to `refresh_interval` for the editor to show
1067                // *something* is the most user-visible source of latency. After
1068                // the first publish, subsequent diagnostics accumulate and are
1069                // flushed every `refresh_interval`.
1070                let mut first_published = false;
1071                let mut t = std::time::Instant::now();
1072                loop {
1073                    let mut got_new = false;
1074                    tokio::select! {
1075                        result = rx.recv_async() => {
1076                            match accumulate_diagnostics(result, &mut diagnostics_map) {
1077                                AccumulateResult::Closed => break,
1078                                AccumulateResult::NewDiagnostic => got_new = true,
1079                                AccumulateResult::Duplicate => {}
1080                            }
1081                        }
1082                        _ = tokio::time::sleep_until(tokio::time::Instant::from_std(t + refresh_interval)) => {}
1083                    }
1084
1085                    let publish_first = got_new && !first_published;
1086                    if publish_first || t.elapsed() >= refresh_interval {
1087                        for (url, (diagnostics, _seen, dirty)) in diagnostics_map.iter_mut() {
1088                            if *dirty {
1089                                consumer_client
1090                                    .publish_diagnostics(url.clone(), diagnostics.clone(), Some(version))
1091                                    .await;
1092                                *dirty = false;
1093                            }
1094                        }
1095                        if publish_first {
1096                            tracing::debug!("first diagnostic published; switching to refresh-interval cadence");
1097                            first_published = true;
1098                        }
1099                        t = std::time::Instant::now();
1100                    }
1101                }
1102            } else {
1103                loop {
1104                    if matches!(
1105                        accumulate_diagnostics(rx.recv_async().await, &mut diagnostics_map),
1106                        AccumulateResult::Closed
1107                    ) {
1108                        break;
1109                    }
1110                }
1111            }
1112
1113            diagnostics_map
1114        };
1115
1116        let consumer_handle = tokio::spawn(diagnostic_consumer);
1117
1118        let result = tokio::select! {
1119            result = cargo_future => {
1120                result.map(|_| false)
1121            },
1122            () = cancel_token.cancelled() => {
1123                tracing::info!("cargo run cancelled by newer request");
1124                Ok(true)
1125            }
1126        };
1127
1128        let was_cancelled = match result {
1129            Ok(t) => t,
1130            Err(error) => {
1131                // We know there wont be any diagnostics as they way we detect cargo errors is
1132                // if it exists with non 0 exit code and no diagnostics were found
1133                tracing::error!(?error, "error building diagnostics");
1134                progress.finish().await;
1135                let _ = consumer_handle.await;
1136                self.client.log_message(MessageType::ERROR, format!("{error}")).await;
1137                self.client.show_message(MessageType::ERROR, format!("{error}")).await;
1138                return;
1139            }
1140        };
1141
1142        if was_cancelled {
1143            // The newer run that triggered cancellation owns publishing. Touching
1144            // files_with_diags or publishing partial results here would race with
1145            // it and could push stale diagnostics on top of correct ones.
1146            let _ = consumer_handle.await;
1147            progress.finish_with_message("cancelled by user").await;
1148            return;
1149        }
1150
1151        tracing::info!("cargo run finished, collecting diagnostics");
1152
1153        let mut diagnostics = match consumer_handle.await {
1154            Ok(d) => d,
1155            Err(error) => {
1156                tracing::error!(?error, "diagnostics fetching task panicked");
1157                progress.finish().await;
1158                self.client.log_message(MessageType::ERROR, format!("{error}")).await;
1159                self.client.show_message(MessageType::ERROR, format!("{error}")).await;
1160                return;
1161            }
1162        };
1163
1164        let mut state = self.state.write().await;
1165        let Some(BackendRuntime::Cargo {
1166            config,
1167            runtime: cargo_rt,
1168        }) = &mut state.backend
1169        else {
1170            // This should be impossible to land here, if we do there a logic error
1171            tracing::error!("backend changed during cargo run");
1172            return;
1173        };
1174        let publish_mode = config.publish_mode;
1175
1176        // In CancelRunning mode a newer run may have started after our cargo
1177        // process finished but before we reached this point. If so our results
1178        // are stale — skip publishing so we don't overwrite the newer run's
1179        // output with old data.
1180        if let PublishMode::CancelRunning = publish_mode
1181            && version != cargo_rt.diagnostics_version
1182        {
1183            tracing::info!(
1184                version,
1185                current = cargo_rt.diagnostics_version,
1186                "skipping stale publish"
1187            );
1188            progress.finish_with_message("superseded by newer run").await;
1189            return;
1190        }
1191
1192        for file in cargo_rt.files_with_diags.drain() {
1193            // Add empty diagnostics so that it get cleared later
1194            let _ = diagnostics.entry(file).or_insert((vec![], HashSet::new(), true));
1195        }
1196
1197        let mut num_warnings = 0;
1198        let mut num_errors = 0;
1199        for (uri, (diagnostics, _seen, is_dirty)) in diagnostics.into_iter() {
1200            tracing::debug!(uri = uri.to_string(), "sent {} cargo diagnostics", diagnostics.len());
1201            for diagnostic in &diagnostics {
1202                match diagnostic.severity {
1203                    Some(DiagnosticSeverity::ERROR) => num_errors += 1,
1204                    Some(DiagnosticSeverity::WARNING) => num_warnings += 1,
1205                    Some(_) | None => {}
1206                }
1207            }
1208            if !diagnostics.is_empty() {
1209                let _ = cargo_rt.files_with_diags.insert(uri.clone());
1210            }
1211            if is_dirty {
1212                self.client.publish_diagnostics(uri, diagnostics, Some(version)).await;
1213            }
1214        }
1215        let message = format!("done, errors: {num_errors}, warnings: {num_warnings}");
1216        progress.finish_with_message(message).await;
1217
1218        if let PublishMode::QueueIfRunning = publish_mode {
1219            match cargo_rt.run_state {
1220                CargoRunState::RunningPending => {
1221                    cargo_rt.run_state = CargoRunState::Idle;
1222                    drop(state);
1223                    tracing::info!("re-running cargo after queued request");
1224                    Box::pin(self.publish_cargo_diagnostics()).await;
1225                }
1226                _ => {
1227                    cargo_rt.run_state = CargoRunState::Idle;
1228                    drop(state);
1229                }
1230            }
1231        }
1232    }
1233
1234    /// Lazy-build (or fetch) the live shadow workspace. Returns `None` if the
1235    /// project root isn't known or the build fails — callers should treat
1236    /// that as "skip this live update", not as a hard error.
1237    pub(crate) async fn ensure_shadow_built(&self) -> Option<ShadowWorkspace> {
1238        // Fast path: shadow already built.
1239        {
1240            let state = self.state.read().await;
1241            if let Some(BackendRuntime::Cargo { runtime, .. }) = &state.backend
1242                && let Some(shadow) = &runtime.shadow
1243            {
1244                return Some(shadow.clone());
1245            }
1246        }
1247
1248        let project_root = {
1249            let state = self.state.read().await;
1250            state.project_root.clone()
1251        };
1252        let Some(root) = project_root else {
1253            tracing::warn!("updateOnInsert: no project root; cannot build live shadow");
1254            return None;
1255        };
1256
1257        tracing::info!(root = ?root, "updateOnInsert: building live shadow workspace");
1258        // Surface this to the user — it's a one-time, multi-second cost
1259        // (tree walk + hardlink fan-out + cold cargo target dir) and
1260        // without a heads-up they'd just see the editor go quiet on the
1261        // first keystroke.
1262        self.client
1263            .show_message(
1264                MessageType::INFO,
1265                "bacon-ls: building live diagnostics shadow workspace (first run only)…",
1266            )
1267            .await;
1268        let shadow = match ShadowWorkspace::build(root).await {
1269            Ok(s) => s,
1270            Err(e) => {
1271                tracing::error!("updateOnInsert: failed to build shadow: {e}");
1272                self.client
1273                    .show_message(
1274                        MessageType::ERROR,
1275                        format!("bacon-ls: failed to build live shadow workspace: {e}"),
1276                    )
1277                    .await;
1278                return None;
1279            }
1280        };
1281
1282        // Stash; if a parallel did_change raced us and built one too, ours
1283        // overwrites — both reflect the same on-disk tree.
1284        let mut state = self.state.write().await;
1285        if let Some(BackendRuntime::Cargo { runtime, .. }) = &mut state.backend {
1286            runtime.shadow = Some(shadow.clone());
1287        }
1288        drop(state);
1289        // Quieter signal that the shadow is ready — goes to the LSP trace
1290        // pane rather than popping a second toast.
1291        self.client
1292            .log_message(
1293                MessageType::INFO,
1294                "bacon-ls: live diagnostics shadow ready; subsequent edits will be checked as you type.",
1295            )
1296            .await;
1297        Some(shadow)
1298    }
1299
1300    /// Apply a dirty buffer (from `did_change`) to the shadow workspace.
1301    /// Tracks the URI in `dirty_files` so we can revert it later via
1302    /// `restore_shadow_link_if_dirty` on `did_save` / `did_close`.
1303    pub(crate) async fn live_update_dirty(&self, uri: Uri, content: String) {
1304        let Some(real_path_cow) = uri.to_file_path() else {
1305            tracing::warn!(uri = uri.as_str(), "updateOnInsert: did_change uri is not a file path");
1306            return;
1307        };
1308        let real_path = real_path_cow.into_owned();
1309
1310        let Some(shadow) = self.ensure_shadow_built().await else {
1311            tracing::warn!("updateOnInsert: shadow workspace not available; skipping live update");
1312            return;
1313        };
1314        if let Err(e) = shadow.write_dirty(&real_path, &content).await {
1315            tracing::warn!(path = ?real_path, ?e, "updateOnInsert: shadow write failed (file outside workspace?)");
1316            return;
1317        }
1318
1319        let debounce = {
1320            let mut state = self.state.write().await;
1321            let Some(BackendRuntime::Cargo { config, runtime }) = &mut state.backend else {
1322                return;
1323            };
1324            runtime.dirty_files.insert(uri.clone());
1325            config.update_on_insert_debounce
1326        };
1327
1328        tracing::info!(
1329            uri = uri.as_str(),
1330            debounce_ms = debounce.as_millis() as u64,
1331            "updateOnInsert: shadow updated, scheduling live cargo run"
1332        );
1333        self.schedule_live_run(debounce).await;
1334    }
1335
1336    /// Schedule (or reschedule) a live cargo run to fire after `delay` of
1337    /// idle time. Cancels any previously-scheduled live trigger so a burst of
1338    /// keystrokes coalesces into a single run.
1339    pub(crate) async fn schedule_live_run(&self, delay: Duration) {
1340        let mut state = self.state.write().await;
1341        let Some(BackendRuntime::Cargo { runtime, .. }) = &mut state.backend else {
1342            return;
1343        };
1344        if let Some(prev) = runtime.live_debounce.take() {
1345            prev.abort();
1346        }
1347        let bacon = self.clone();
1348        runtime.live_debounce = Some(tokio::spawn(async move {
1349            tokio::time::sleep(delay).await;
1350            bacon.publish_cargo_diagnostics_live().await;
1351        }));
1352    }
1353
1354    /// Cancel any pending debounced live trigger. Called on `did_save` so
1355    /// the on-save cargo run (against the real workspace) is the canonical
1356    /// one and a soon-to-be-stale live run doesn't race it.
1357    pub(crate) async fn cancel_live_debounce(&self) {
1358        let mut state = self.state.write().await;
1359        if let Some(BackendRuntime::Cargo { runtime, .. }) = &mut state.backend
1360            && let Some(handle) = runtime.live_debounce.take()
1361        {
1362            handle.abort();
1363        }
1364    }
1365
1366    /// On `did_save` / `did_close`, replace the (possibly dirty) shadow file
1367    /// with a fresh hardlink to the on-disk version, and forget the URI.
1368    pub(crate) async fn restore_shadow_link_if_dirty(&self, uri: &Uri) {
1369        let (shadow, real_path) = {
1370            let mut state = self.state.write().await;
1371            let Some(BackendRuntime::Cargo { runtime, .. }) = &mut state.backend else {
1372                return;
1373            };
1374            if !runtime.dirty_files.remove(uri) {
1375                return;
1376            }
1377            let Some(shadow) = runtime.shadow.clone() else {
1378                return;
1379            };
1380            let Some(path_cow) = uri.to_file_path() else {
1381                return;
1382            };
1383            (shadow, path_cow.into_owned())
1384        };
1385        if let Err(e) = shadow.restore_link(&real_path).await {
1386            tracing::warn!(path = ?real_path, ?e, "updateOnInsert: failed to restore shadow link");
1387        }
1388    }
1389
1390    async fn publish_bacon_diagnostics(&self, uri: &Uri) {
1391        let mut guard = self.state.write().await;
1392        let workspace_folders = guard.workspace_folders.clone();
1393
1394        let Some(BackendRuntime::Bacon { config, runtime }) = &mut guard.backend else {
1395            return;
1396        };
1397        tracing::info!(uri = uri.to_string(), "publish bacon diagnostics");
1398        let locations_file_name = config.locations_file.clone();
1399        runtime.diagnostics_version = runtime.diagnostics_version.wrapping_add(1);
1400        let version = runtime.diagnostics_version;
1401        drop(guard);
1402        Bacon::publish_diagnostics(
1403            &self.client,
1404            uri,
1405            &locations_file_name,
1406            workspace_folders.as_deref(),
1407            version,
1408        )
1409        .await;
1410    }
1411
1412    async fn synchronize_diagnostics(state: Arc<RwLock<State>>, client: Arc<Client>) {
1413        Bacon::synchronize_diagnostics(state, client).await;
1414    }
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419    use super::*;
1420
1421    #[test]
1422    fn test_can_configure_tracing() {
1423        // Direct the test's log file into a tempdir so we don't clobber the
1424        // developer's `bacon-ls.log` in the workspace root (which is what
1425        // `cargo run` / a live editor session writes to).
1426        let tmp = tempfile::tempdir().expect("tempdir");
1427        let log_path = tmp.path().join("bacon-ls.log");
1428        BaconLs::configure_tracing(Some("info".to_string()), Some(&log_path));
1429    }
1430
1431    #[test]
1432    fn test_path_to_file_uri_plain_ascii() {
1433        let uri = path_to_file_uri("/home/me/src/lib.rs");
1434        assert_eq!(uri, "file:///home/me/src/lib.rs");
1435        let parsed = uri.parse::<Uri>().expect("must parse as Uri");
1436        assert_eq!(parsed.path().as_str(), "/home/me/src/lib.rs");
1437    }
1438
1439    #[test]
1440    fn test_path_to_file_uri_escapes_space_and_hash_and_percent() {
1441        let uri = path_to_file_uri("/home/me/My Projects/tests#1/file%.rs");
1442        assert_eq!(uri, "file:///home/me/My%20Projects/tests%231/file%25.rs");
1443        let parsed = uri.parse::<Uri>().expect("must parse as Uri");
1444        // Uri preserves the encoded form on the wire; clients are responsible
1445        // for decoding. We only need to confirm the parse succeeds.
1446        assert_eq!(parsed.path().as_str(), "/home/me/My%20Projects/tests%231/file%25.rs");
1447    }
1448
1449    #[test]
1450    fn test_path_to_file_uri_preserves_path_separators() {
1451        // The `/` separator must NOT be encoded, or clients can't recognize
1452        // segment structure.
1453        let uri = path_to_file_uri("/a/b/c");
1454        assert_eq!(uri, "file:///a/b/c");
1455    }
1456
1457    #[test]
1458    fn test_path_to_file_uri_relative_path_preserves_segments() {
1459        // Cargo emits relative paths (e.g. "src/lib.rs") in JSON output. The
1460        // current `deserialize_url` hack turns those into URIs with the first
1461        // segment as "host" — percent-encoding must not break that.
1462        let uri = path_to_file_uri("src/lib.rs");
1463        assert_eq!(uri, "file://src/lib.rs");
1464        let parsed = uri.parse::<Uri>().expect("must parse as Uri");
1465        assert_eq!(
1466            parsed.authority().map(|a| a.host().to_string()),
1467            Some("src".to_string())
1468        );
1469        assert_eq!(parsed.path().as_str(), "/lib.rs");
1470    }
1471
1472    #[test]
1473    fn test_cancel_mode_replaces_token() {
1474        let original = CancellationToken::new();
1475        let token = original.clone();
1476        token.cancel();
1477        assert!(original.is_cancelled());
1478        let new_token = CancellationToken::new();
1479        assert!(!new_token.is_cancelled());
1480    }
1481
1482    #[test]
1483    fn test_detect_backend_explicit_cargo() {
1484        let values: Map<String, Value> = serde_json::from_str(r#"{"backend": "cargo"}"#).unwrap();
1485        assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Cargo);
1486    }
1487
1488    #[test]
1489    fn test_detect_backend_explicit_bacon() {
1490        let values: Map<String, Value> = serde_json::from_str(r#"{"backend": "bacon"}"#).unwrap();
1491        assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Bacon);
1492    }
1493
1494    #[test]
1495    fn test_detect_backend_invalid_value() {
1496        let values: Map<String, Value> = serde_json::from_str(r#"{"backend": "invalid"}"#).unwrap();
1497        assert!(BaconLs::detect_backend(&values).is_err());
1498    }
1499
1500    #[test]
1501    fn test_detect_backend_infer_from_cargo_key() {
1502        let values: Map<String, Value> = serde_json::from_str(r#"{"cargo": {"command": "check"}}"#).unwrap();
1503        assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Cargo);
1504    }
1505
1506    #[test]
1507    fn test_detect_backend_infer_from_bacon_key() {
1508        let values: Map<String, Value> =
1509            serde_json::from_str(r#"{"bacon": {"locationsFile": ".bacon-locations"}}"#).unwrap();
1510        assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Bacon);
1511    }
1512
1513    #[test]
1514    fn test_detect_backend_both_keys_error() {
1515        let values: Map<String, Value> = serde_json::from_str(r#"{"cargo": {}, "bacon": {}}"#).unwrap();
1516        assert!(BaconLs::detect_backend(&values).is_err());
1517    }
1518
1519    #[test]
1520    fn test_detect_backend_no_keys_defaults_to_cargo() {
1521        let values: Map<String, Value> = serde_json::from_str(r#"{}"#).unwrap();
1522        assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Cargo);
1523    }
1524
1525    #[test]
1526    fn test_detect_backend_explicit_overrides_keys() {
1527        let values: Map<String, Value> = serde_json::from_str(r#"{"backend": "cargo", "bacon": {}}"#).unwrap();
1528        assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Cargo);
1529    }
1530
1531    #[test]
1532    fn test_cargo_options_build_args_default() {
1533        let args = CargoOptions::default().build_command_args();
1534        assert_eq!(args, vec!["check", "--message-format=json-diagnostic-rendered-ansi"]);
1535    }
1536
1537    #[test]
1538    fn test_cargo_options_build_args_with_features() {
1539        let opts = CargoOptions {
1540            features: vec!["a".into(), "b".into(), "c".into()],
1541            ..CargoOptions::default()
1542        };
1543        let args = opts.build_command_args();
1544        assert_eq!(
1545            args,
1546            vec![
1547                "check",
1548                "--message-format=json-diagnostic-rendered-ansi",
1549                "--features",
1550                "a,b,c"
1551            ]
1552        );
1553    }
1554
1555    #[test]
1556    fn test_cargo_options_build_args_single_feature() {
1557        let opts = CargoOptions {
1558            features: vec!["only".into()],
1559            ..CargoOptions::default()
1560        };
1561        let args = opts.build_command_args();
1562        assert_eq!(
1563            args,
1564            vec![
1565                "check",
1566                "--message-format=json-diagnostic-rendered-ansi",
1567                "--features",
1568                "only"
1569            ]
1570        );
1571    }
1572
1573    #[test]
1574    fn test_cargo_options_build_args_with_package_and_extras() {
1575        let opts = CargoOptions {
1576            command: "clippy".into(),
1577            package: Some("my-crate".into()),
1578            extra_command_args: vec!["--workspace".into(), "--all-targets".into()],
1579            ..CargoOptions::default()
1580        };
1581        let args = opts.build_command_args();
1582        assert_eq!(
1583            args,
1584            vec![
1585                "clippy",
1586                "--message-format=json-diagnostic-rendered-ansi",
1587                "-p",
1588                "my-crate",
1589                "--workspace",
1590                "--all-targets",
1591            ]
1592        );
1593    }
1594
1595    #[test]
1596    fn test_cargo_options_update_from_json_full_roundtrip() {
1597        let mut opts = CargoOptions::default();
1598        let json = serde_json::json!({
1599            "command": "clippy",
1600            "features": ["a", "b"],
1601            "package": "pkg",
1602            "extraArgs": ["--workspace"],
1603            "env": {"RUST_LOG": "trace"},
1604            "cancelRunning": false,
1605            "refreshIntervalSeconds": 10,
1606            "separateChildDiagnostics": true,
1607            "checkOnSave": false,
1608            "clearDiagnosticsOnCheck": true,
1609            "updateOnInsertDebounceMillis": 250,
1610        });
1611        let obj = json.as_object().unwrap();
1612        opts.update_from_json_obj(obj).expect("should parse");
1613        assert_eq!(opts.command, "clippy");
1614        assert_eq!(opts.features, vec!["a".to_string(), "b".to_string()]);
1615        assert_eq!(opts.package.as_deref(), Some("pkg"));
1616        assert_eq!(opts.extra_command_args, vec!["--workspace".to_string()]);
1617        assert_eq!(opts.env, vec![("RUST_LOG".into(), "trace".into())]);
1618        assert!(matches!(opts.publish_mode, PublishMode::QueueIfRunning));
1619        assert_eq!(opts.refresh_interval_seconds, Some(Duration::from_secs(10)));
1620        assert_eq!(opts.separate_child_diagnostics, Some(true));
1621        assert!(!opts.check_on_save);
1622        assert!(opts.clear_diagnostics_on_check);
1623        assert_eq!(opts.update_on_insert_debounce, Duration::from_millis(250));
1624    }
1625
1626    #[test]
1627    fn test_cargo_options_update_on_insert_defaults_off() {
1628        let opts = CargoOptions::default();
1629        assert!(!opts.update_on_insert);
1630        assert_eq!(opts.update_on_insert_debounce, Duration::from_millis(500));
1631    }
1632
1633    #[test]
1634    fn test_cargo_options_update_on_insert_debounce_rejects_negative() {
1635        let mut opts = CargoOptions::default();
1636        let json = serde_json::json!({"updateOnInsertDebounceMillis": -50});
1637        assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
1638    }
1639
1640    #[test]
1641    fn test_cargo_options_update_from_json_refresh_null_means_no_partial() {
1642        let mut opts = CargoOptions::default();
1643        let json = serde_json::json!({"refreshIntervalSeconds": null});
1644        opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
1645        assert_eq!(opts.refresh_interval_seconds, None);
1646    }
1647
1648    #[test]
1649    fn test_cargo_options_update_from_json_refresh_negative_means_no_partial() {
1650        let mut opts = CargoOptions::default();
1651        let json = serde_json::json!({"refreshIntervalSeconds": -1});
1652        opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
1653        assert_eq!(opts.refresh_interval_seconds, None);
1654    }
1655
1656    #[test]
1657    fn test_cargo_options_update_from_json_rejects_wrong_type() {
1658        let mut opts = CargoOptions::default();
1659        let json = serde_json::json!({"command": 42});
1660        assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
1661    }
1662
1663    #[test]
1664    fn test_cargo_options_update_from_json_partial_leaves_others_unchanged() {
1665        let mut opts = CargoOptions {
1666            command: "clippy".into(),
1667            ..CargoOptions::default()
1668        };
1669        let json = serde_json::json!({"checkOnSave": false});
1670        opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
1671        assert_eq!(opts.command, "clippy");
1672        assert!(!opts.check_on_save);
1673    }
1674
1675    #[test]
1676    fn test_cargo_options_reset_restores_defaults() {
1677        let mut opts = CargoOptions {
1678            command: "clippy".into(),
1679            features: vec!["foo".into()],
1680            check_on_save: false,
1681            ..CargoOptions::default()
1682        };
1683        opts.reset();
1684        let defaults = CargoOptions::default();
1685        assert_eq!(opts.command, defaults.command);
1686        assert_eq!(opts.features, defaults.features);
1687        assert_eq!(opts.check_on_save, defaults.check_on_save);
1688    }
1689
1690    #[test]
1691    fn test_bacon_options_update_from_json_full_roundtrip() {
1692        let mut opts = BaconOptions::default();
1693        let json = serde_json::json!({
1694            "locationsFile": "custom.locations",
1695            "runInBackground": false,
1696            "runInBackgroundCommand": "/usr/local/bin/bacon",
1697            "runInBackgroundCommandArguments": "--headless -j custom",
1698            "validatePreferences": false,
1699            "createPreferencesFile": false,
1700            "synchronizeAllOpenFilesWaitMillis": 500,
1701            "updateOnSave": false,
1702            "updateOnSaveWaitMillis": 250,
1703        });
1704        opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
1705        assert_eq!(opts.locations_file, "custom.locations");
1706        assert!(!opts.run_in_background);
1707        assert_eq!(opts.run_in_background_command, "/usr/local/bin/bacon");
1708        assert_eq!(opts.run_in_background_command_args, "--headless -j custom");
1709        assert!(!opts.validate_preferences);
1710        assert!(!opts.create_preferences_file);
1711        assert_eq!(opts.synchronize_all_open_files_wait, Duration::from_millis(500));
1712        assert!(!opts.update_on_save);
1713        assert_eq!(opts.update_on_save_wait, Duration::from_millis(250));
1714    }
1715
1716    #[test]
1717    fn test_bacon_options_update_from_json_rejects_wrong_type() {
1718        let mut opts = BaconOptions::default();
1719        let json = serde_json::json!({"runInBackground": "yes"});
1720        assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
1721    }
1722
1723    #[test]
1724    fn test_bacon_options_reset_restores_defaults() {
1725        let mut opts = BaconOptions {
1726            run_in_background: false,
1727            locations_file: "foo".into(),
1728            ..BaconOptions::default()
1729        };
1730        opts.reset();
1731        let defaults = BaconOptions::default();
1732        assert_eq!(opts.run_in_background, defaults.run_in_background);
1733        assert_eq!(opts.locations_file, defaults.locations_file);
1734    }
1735
1736    #[test]
1737    fn test_correction_from_single_empty_is_remove() {
1738        let range = Range::default();
1739        let c = Correction::from_single(range, "");
1740        assert_eq!(c.label, "Remove");
1741        assert_eq!(c.edits.len(), 1);
1742        assert_eq!(c.edits[0].new_text, "");
1743    }
1744
1745    #[test]
1746    fn test_correction_from_single_nonempty_is_replace() {
1747        let range = Range::default();
1748        let c = Correction::from_single(range, "foo");
1749        assert_eq!(c.label, "Replace with: foo");
1750        assert_eq!(c.edits.len(), 1);
1751    }
1752
1753    #[test]
1754    fn test_correction_from_multi_all_empty_is_remove() {
1755        let edits = vec![
1756            CorrectionEdit {
1757                range: Range::default(),
1758                new_text: "".into(),
1759            },
1760            CorrectionEdit {
1761                range: Range::default(),
1762                new_text: "".into(),
1763            },
1764        ];
1765        let c = Correction::from_multi(edits);
1766        assert_eq!(c.label, "Remove");
1767        assert_eq!(c.edits.len(), 2);
1768    }
1769
1770    #[test]
1771    fn test_correction_from_multi_labels_by_first_nonempty() {
1772        let edits = vec![
1773            CorrectionEdit {
1774                range: Range::default(),
1775                new_text: "".into(),
1776            },
1777            CorrectionEdit {
1778                range: Range::default(),
1779                new_text: "new".into(),
1780            },
1781        ];
1782        let c = Correction::from_multi(edits);
1783        assert_eq!(c.label, "Replace with: new");
1784    }
1785
1786    #[test]
1787    fn test_severity_tag_distinguishes_levels() {
1788        assert_eq!(severity_tag(None), 0);
1789        assert_eq!(severity_tag(Some(DiagnosticSeverity::ERROR)), 1);
1790        assert_eq!(severity_tag(Some(DiagnosticSeverity::WARNING)), 2);
1791        assert_eq!(severity_tag(Some(DiagnosticSeverity::INFORMATION)), 3);
1792        assert_eq!(severity_tag(Some(DiagnosticSeverity::HINT)), 4);
1793        // All four constants must hash to distinct tags or dedup will fold
1794        // legitimately-different diagnostics together.
1795        let tags = [
1796            severity_tag(Some(DiagnosticSeverity::ERROR)),
1797            severity_tag(Some(DiagnosticSeverity::WARNING)),
1798            severity_tag(Some(DiagnosticSeverity::INFORMATION)),
1799            severity_tag(Some(DiagnosticSeverity::HINT)),
1800        ];
1801        let unique: HashSet<_> = tags.iter().collect();
1802        assert_eq!(unique.len(), tags.len());
1803    }
1804
1805    #[test]
1806    fn test_diag_key_collides_for_equal_diagnostics() {
1807        let a = Diagnostic {
1808            range: Range::default(),
1809            severity: Some(DiagnosticSeverity::ERROR),
1810            message: "hi".into(),
1811            ..Diagnostic::default()
1812        };
1813        let b = a.clone();
1814        assert_eq!(diag_key(&a), diag_key(&b));
1815    }
1816
1817    #[test]
1818    fn test_diag_key_differs_when_message_differs() {
1819        let mut a = Diagnostic {
1820            range: Range::default(),
1821            severity: Some(DiagnosticSeverity::ERROR),
1822            message: "first".into(),
1823            ..Diagnostic::default()
1824        };
1825        let b = a.clone();
1826        a.message = "second".into();
1827        assert_ne!(diag_key(&a), diag_key(&b));
1828    }
1829
1830    #[test]
1831    fn test_path_to_file_uri_empty_path() {
1832        // Empty path yields the trivial `file://` URI. Useful guard against
1833        // future regressions in the encoding helper when fed degenerate input.
1834        assert_eq!(path_to_file_uri(""), "file://");
1835    }
1836
1837    #[test]
1838    fn test_correction_from_single_label_replaces_with_text() {
1839        let c = Correction::from_single(Range::default(), "x");
1840        assert_eq!(c.label, "Replace with: x");
1841        assert_eq!(c.edits.len(), 1);
1842        assert_eq!(c.edits[0].new_text, "x");
1843    }
1844
1845    #[test]
1846    fn test_correction_from_multi_empty_edits_is_remove() {
1847        let c = Correction::from_multi(vec![]);
1848        assert_eq!(c.label, "Remove");
1849        assert!(c.edits.is_empty());
1850    }
1851
1852    #[test]
1853    fn test_cargo_options_env_roundtrip_preserves_order_in_serde_iteration() {
1854        // serde_json::Map preserves insertion order. We rely on that for
1855        // reproducible env propagation into cargo.
1856        let mut opts = CargoOptions::default();
1857        let json = serde_json::json!({
1858            "env": {"A": "1", "B": "2", "C": "3"}
1859        });
1860        opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
1861        assert_eq!(opts.env.len(), 3);
1862        let keys: Vec<_> = opts.env.iter().map(|(k, _)| k.as_str()).collect();
1863        assert_eq!(keys, vec!["A", "B", "C"]);
1864    }
1865
1866    #[test]
1867    fn test_cargo_options_update_rejects_non_object_env() {
1868        let mut opts = CargoOptions::default();
1869        let json = serde_json::json!({"env": ["A=1"]});
1870        assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
1871    }
1872
1873    #[test]
1874    fn test_cargo_options_update_rejects_non_string_env_value() {
1875        let mut opts = CargoOptions::default();
1876        let json = serde_json::json!({"env": {"A": 1}});
1877        assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
1878    }
1879
1880    #[test]
1881    fn test_cargo_options_update_rejects_non_string_feature_item() {
1882        let mut opts = CargoOptions::default();
1883        let json = serde_json::json!({"features": ["a", 2, "c"]});
1884        assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
1885    }
1886
1887    #[test]
1888    fn test_cargo_options_publish_mode_toggle_via_cancel_running() {
1889        let mut opts = CargoOptions::default();
1890        // Default is CancelRunning.
1891        assert!(matches!(opts.publish_mode, PublishMode::CancelRunning));
1892        opts.update_from_json_obj(serde_json::json!({"cancelRunning": false}).as_object().unwrap())
1893            .unwrap();
1894        assert!(matches!(opts.publish_mode, PublishMode::QueueIfRunning));
1895        opts.update_from_json_obj(serde_json::json!({"cancelRunning": true}).as_object().unwrap())
1896            .unwrap();
1897        assert!(matches!(opts.publish_mode, PublishMode::CancelRunning));
1898    }
1899
1900    #[test]
1901    fn test_cargo_options_separate_child_diagnostics_can_unset() {
1902        let mut opts = CargoOptions {
1903            separate_child_diagnostics: Some(true),
1904            ..CargoOptions::default()
1905        };
1906        // `as_bool()` on a non-bool returns None — and we feed that through
1907        // unchanged, so a `null` (or anything non-bool) clears the override.
1908        opts.update_from_json_obj(
1909            serde_json::json!({"separateChildDiagnostics": null})
1910                .as_object()
1911                .unwrap(),
1912        )
1913        .unwrap();
1914        assert_eq!(opts.separate_child_diagnostics, None);
1915    }
1916
1917    #[tokio::test]
1918    async fn test_find_git_root_directory_returns_none_outside_git() {
1919        let tmp = tempfile::TempDir::new().unwrap();
1920        let root = BaconLs::find_git_root_directory(tmp.path()).await;
1921        assert_eq!(root, None);
1922    }
1923
1924    #[tokio::test]
1925    async fn test_find_git_root_directory_finds_top_of_repo() {
1926        // `git -C <subdir> rev-parse --show-toplevel` should resolve to the
1927        // crate's own repo root regardless of which subdirectory we point at.
1928        let crate_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1929        let src = crate_root.join("src");
1930        let from_subdir = BaconLs::find_git_root_directory(&src).await;
1931        assert!(from_subdir.is_some(), "src/ is inside a git repo");
1932        let from_root = BaconLs::find_git_root_directory(crate_root).await.unwrap();
1933        // Both lookups should resolve to the same toplevel.
1934        assert_eq!(from_subdir.unwrap(), from_root);
1935    }
1936
1937    #[test]
1938    fn test_init_cargo_backend_uses_existing_project_root() {
1939        let tmp = tempfile::TempDir::new().unwrap();
1940        let root = tmp.path().to_path_buf();
1941        let mut state = State {
1942            project_root: Some(root.clone()),
1943            ..State::default()
1944        };
1945        // Normally we'd hold an RwLockWriteGuard, but for this unit test we
1946        // adapt the API by going through a real lock.
1947        let lock = RwLock::new(std::mem::take(&mut state));
1948        let mut guard = lock.try_write().unwrap();
1949        BaconLs::init_cargo_backend(&mut guard, CargoOptions::default())
1950            .expect("init should succeed with explicit project root");
1951        match &guard.backend {
1952            Some(BackendRuntime::Cargo { runtime, .. }) => {
1953                assert_eq!(runtime.build_folder, root);
1954                assert_eq!(runtime.run_state, CargoRunState::Idle);
1955                assert_eq!(runtime.diagnostics_version, 0);
1956            }
1957            other => panic!("expected Cargo backend, got {other:?}"),
1958        }
1959    }
1960
1961    #[test]
1962    fn test_init_cargo_backend_falls_back_to_cwd_when_no_project_root() {
1963        let mut state = State::default();
1964        let lock = RwLock::new(std::mem::take(&mut state));
1965        let mut guard = lock.try_write().unwrap();
1966        BaconLs::init_cargo_backend(&mut guard, CargoOptions::default())
1967            .expect("init should fall back to CWD when project root is unset");
1968        match &guard.backend {
1969            Some(BackendRuntime::Cargo { runtime, .. }) => {
1970                let cwd = std::env::current_dir().unwrap();
1971                assert_eq!(runtime.build_folder, cwd, "should fall back to CWD");
1972            }
1973            other => panic!("expected Cargo backend, got {other:?}"),
1974        }
1975    }
1976
1977    #[test]
1978    fn test_cargo_options_build_args_with_env_does_not_leak_into_args() {
1979        // Sanity: env values are not added as command-line args.
1980        let opts = CargoOptions {
1981            env: vec![("A".into(), "1".into())],
1982            ..CargoOptions::default()
1983        };
1984        let args = opts.build_command_args();
1985        assert!(args.iter().all(|a| !a.contains("A=1") && !a.contains("=1")));
1986    }
1987}