Skip to main content

deps_cargo/
parser.rs

1//! Cargo.toml parser with position tracking.
2//!
3//! Parses Cargo.toml files using toml-span to preserve formatting and extract
4//! precise LSP positions for every dependency field. Critical for features like
5//! hover, completion, and inlay hints.
6//!
7//! # Key Features
8//!
9//! - Position-preserving parsing via toml-span spans
10//! - Handles all dependency formats: inline, table, workspace inheritance
11//! - Extracts dependencies from all sections: dependencies, dev-dependencies, build-dependencies,
12//!   including their `[target.<cfg-expr-or-triple>]` variants
13//! - Converts byte offsets to LSP Position (line, UTF-16 character)
14//!
15//! # Examples
16//!
17//! ```no_run
18//! use deps_cargo::parse_cargo_toml;
19//! use tower_lsp_server::ls_types::Uri;
20//!
21//! let toml = r#"
22//! [dependencies]
23//! serde = "1.0"
24//! "#;
25//!
26//! let url = Uri::from_file_path("/test/Cargo.toml").unwrap();
27//! let result = parse_cargo_toml(toml, &url).unwrap();
28//! assert_eq!(result.dependencies.len(), 1);
29//! assert_eq!(result.dependencies[0].name, "serde");
30//! ```
31
32use crate::config::{
33    AuthToken, ConfigFileCache, IndexTrust, RegistryIndex, RegistryIndexError, SourceReplacement,
34};
35use crate::types::{DependencySection, DependencySource, ParsedDependency};
36use deps_core::net_policy::RegistryAccessPolicy;
37use deps_core::{DepsError, Result};
38use std::any::Any;
39use std::collections::{HashMap, HashSet};
40use std::path::PathBuf;
41use std::sync::Arc;
42use toml_span::value::{Table, Value};
43use tower_lsp_server::ls_types::{Range, Uri};
44
45pub use deps_core::lsp_helpers::LineOffsetTable;
46
47/// Result of parsing a Cargo.toml file.
48///
49/// Contains all extracted dependencies with their positions, plus optional
50/// workspace root information for resolving inherited dependencies.
51#[derive(Debug, Clone)]
52pub struct ParseResult {
53    /// All dependencies found in the file
54    pub dependencies: Vec<ParsedDependency>,
55    /// Workspace root path if this is a workspace member
56    pub workspace_root: Option<PathBuf>,
57    /// Document URI
58    pub uri: Uri,
59    /// Every alternate-registry index this parse resolved (spec FR-002), paired with the
60    /// credential (if any) to attach to requests against it.
61    ///
62    /// `crate::ecosystem::CargoEcosystem::parse_manifest` registers each of these into the
63    /// shared `CargoRegistry` router immediately after parsing, so a later
64    /// `Registry::get_versions_from` call for the matching
65    /// `DependencySource::AlternateRegistry` source can find its (possibly authenticated)
66    /// client. Empty when [`Self::dependencies`] contains no `CustomRegistry` source *and*
67    /// no `[source.crates-io] replace-with` chain resolves to a sparse mirror — 1a's
68    /// zero-extra-work lazy trigger (spec NFR-004) no longer holds unconditionally once 1b's
69    /// `[source]` support is in play, since a mirror can rewrite every plain dependency (spec
70    /// NFR-005's corrected premise).
71    pub resolved_registries: Vec<(RegistryIndex, Option<AuthToken>)>,
72    /// Dependency lines whose `registry`/`registry-index` resolution was blocked by the
73    /// current `registries.workspace_registries` policy (spec #443, plan-1b §1.7) —
74    /// `(name_range, blocked host class, raw declared value)` triples. Surfaced by
75    /// [`deps_core::lsp_helpers::generate_diagnostics_from_cache`] via
76    /// [`Self::blocked_registries`]'s trait override as an informational diagnostic, so the
77    /// block never degrades silently.
78    pub blocked_registries: Vec<(Range, deps_core::net_policy::HostClass, String)>,
79}
80
81/// Parses a Cargo.toml file and extracts all dependencies with positions.
82///
83/// # Errors
84///
85/// Returns an error if:
86/// - TOML syntax is invalid
87/// - File path cannot be converted from URL
88///
89/// # Examples
90///
91/// ```no_run
92/// use deps_cargo::parse_cargo_toml;
93/// use tower_lsp_server::ls_types::Uri;
94///
95/// let toml = r#"
96/// [dependencies]
97/// serde = "1.0"
98/// tokio = { version = "1.0", features = ["full"] }
99/// "#;
100///
101/// let url = Uri::from_file_path("/test/Cargo.toml").unwrap();
102/// let result = parse_cargo_toml(toml, &url).unwrap();
103/// assert_eq!(result.dependencies.len(), 2);
104/// ```
105pub fn parse_cargo_toml(content: &str, doc_uri: &Uri) -> Result<ParseResult> {
106    parse_cargo_toml_with_context(content, doc_uri, &CargoParseContext::default())
107}
108
109/// Carries the two pieces of process-wide state a Cargo parse needs beyond its own manifest
110/// content.
111///
112/// The live workspace-registry reachability policy (spec #443, `registries.workspace_registries`)
113/// and the `.cargo/config.toml` memoization cache (spec NFR-005, plan-1b §1.5) — plumbed
114/// together so [`crate::ecosystem::CargoEcosystem::with_context`] has one thing to hold and
115/// pass through the sync parser (plan-1b §1.6), shared across every document this ecosystem
116/// parses.
117#[derive(Clone)]
118pub struct CargoParseContext {
119    /// Gates every `IndexTrust::WorkspaceDeclared` [`RegistryIndex`] this parse constructs.
120    pub policy: Arc<RegistryAccessPolicy>,
121    /// Memoizes each distinct `.cargo/config.toml`/`$CARGO_HOME/config.toml` file's raw,
122    /// unvalidated contents across every parse that reads it.
123    pub config_cache: Arc<ConfigFileCache>,
124}
125
126impl Default for CargoParseContext {
127    fn default() -> Self {
128        Self {
129            policy: Arc::new(RegistryAccessPolicy::default()),
130            config_cache: Arc::new(ConfigFileCache::new()),
131        }
132    }
133}
134
135/// [`parse_cargo_toml`], but threading `ctx` through to alternate-registry resolution.
136///
137/// The real entry point; [`parse_cargo_toml`] delegates here with a fresh, default context
138/// (mirrors this module's own `resolve`/`resolve_with_env` and
139/// `cargo_home_config_path`/`_with_env` pattern), so every pre-existing test/doctest call
140/// site keeps compiling unchanged.
141///
142/// # Errors
143///
144/// Returns an error if:
145/// - TOML syntax is invalid
146/// - File path cannot be converted from URL
147pub fn parse_cargo_toml_with_context(
148    content: &str,
149    doc_uri: &Uri,
150    ctx: &CargoParseContext,
151) -> Result<ParseResult> {
152    if let Err(depth) =
153        deps_core::check_toml_nesting_depth(content, deps_core::MAX_TOML_NESTING_DEPTH)
154    {
155        return Err(DepsError::ParseError {
156            file_type: "Cargo.toml".into(),
157            source: Box::new(std::io::Error::other(format!(
158                "array/table nesting depth {depth} exceeds maximum of {}",
159                deps_core::MAX_TOML_NESTING_DEPTH
160            ))),
161        });
162    }
163
164    let doc = toml_span::parse(content).map_err(|e| DepsError::ParseError {
165        file_type: "Cargo.toml".into(),
166        source: Box::new(std::io::Error::other(e.to_string())),
167    })?;
168
169    let line_table = LineOffsetTable::new(content);
170    let mut dependencies = Vec::new();
171
172    let root_table = doc.as_table().ok_or_else(|| DepsError::ParseError {
173        file_type: "Cargo.toml".into(),
174        source: Box::new(std::io::Error::other("root is not a table")),
175    })?;
176
177    parse_dependency_kind_tables(root_table, content, &line_table, &mut dependencies);
178
179    // Parse target-specific dependency tables: [target.<cfg-expr-or-triple>.dependencies],
180    // .dev-dependencies, .build-dependencies (#392). Each entry under [target] is keyed by a
181    // cfg expression (e.g. `cfg(unix)`) or a target triple; every such table can carry the
182    // same three dependency kinds as the top level.
183    if let Some(target_val) = get_val(root_table, "target")
184        && let Some(target_table) = target_val.as_table()
185    {
186        for target_entry in target_table.values() {
187            if let Some(target_spec_table) = target_entry.as_table() {
188                parse_dependency_kind_tables(
189                    target_spec_table,
190                    content,
191                    &line_table,
192                    &mut dependencies,
193                );
194            }
195        }
196    }
197
198    // Parse workspace dependencies (for workspace root Cargo.toml)
199    if let Some(workspace_val) = get_val(root_table, "workspace")
200        && let Some(workspace_table) = workspace_val.as_table()
201        && let Some(workspace_deps_val) = get_val(workspace_table, "dependencies")
202        && let Some(workspace_deps) = workspace_deps_val.as_table()
203    {
204        dependencies.extend(parse_dependencies_section(
205            workspace_deps,
206            content,
207            &line_table,
208            DependencySection::WorkspaceDependencies,
209        ));
210    }
211
212    let discovery = discover_workspace(doc_uri)?;
213
214    let (resolved_registries, blocked_registries) =
215        resolve_alternate_registries(&mut dependencies, &discovery.config_paths, ctx);
216
217    Ok(ParseResult {
218        dependencies,
219        workspace_root: discovery.workspace_root,
220        uri: doc_uri.clone(),
221        blocked_registries,
222        resolved_registries,
223    })
224}
225
226fn get_val<'a>(table: &'a Table<'a>, key: &str) -> Option<&'a Value<'a>> {
227    table.get(key)
228}
229
230/// Return type of [`resolve_alternate_registries`]: the newly-resolved `(index, auth)`
231/// pairs to register into the shared `CargoRegistry` router, alongside every dependency
232/// line whose registry-index resolution was blocked by policy (spec #443, plan-1b §1.7).
233type AlternateRegistryResolution = (
234    Vec<(RegistryIndex, Option<AuthToken>)>,
235    Vec<(Range, deps_core::net_policy::HostClass, String)>,
236);
237
238/// Rewrites every `DependencySource::CustomRegistry` entry in `dependencies` into a
239/// resolved `DependencySource::AlternateRegistry` when possible (spec FR-002), and every
240/// plain `DependencySource::Registry` entry into a resolved `AlternateRegistry {
241/// mirrors_crates_io: true, .. }` when a `[source.crates-io] replace-with` chain resolves to
242/// a sparse mirror (spec FR-005/006/007, plan-1b §1.4). Returns the newly-resolved `(index,
243/// auth)` pairs for `crate::ecosystem::CargoEcosystem::parse_manifest` to register into the
244/// shared `CargoRegistry` router.
245///
246/// Two distinct forms of `CustomRegistry` reach the alias-resolution half of this function:
247/// - `registry-index = "sparse+https://..."` — `url` is already a concrete index URL, so it
248///   resolves directly via [`RegistryIndex::new`], with no `.cargo/config.toml` lookup and
249///   no possible credential (a literal URL in `Cargo.toml` is workspace-declared by
250///   definition — `auth` is always `None` for this form).
251/// - `registry = "<alias>"` — `url` is an alias name, which does not parse as a URL, so it
252///   falls through to alias resolution via `crate::config::resolve` against the
253///   `.cargo/config.toml` hierarchy plus `$CARGO_HOME/config.toml` (spec FR-003: an alias
254///   with no matching entry stays `CustomRegistry`, unchanged, with a `tracing::warn!`).
255///
256/// Unlike 1a, this is **not** skipped when `dependencies` contains no `CustomRegistry`
257/// source: a `[source]` replace-with chain can rewrite *every* plain dependency, so
258/// `crate::config::resolve` always runs (spec NFR-005's corrected premise — the zero-cost
259/// lazy trigger from 1a no longer holds once 1b's `[source]` support lands). The merged
260/// ancestor walk ([`discover_workspace`]) this function's `workspace_config_paths` comes
261/// from is unconditional regardless, so the only new unconditional cost here is the (cheap,
262/// memoized) config resolution itself.
263fn resolve_alternate_registries(
264    dependencies: &mut [ParsedDependency],
265    workspace_config_paths: &[PathBuf],
266    ctx: &CargoParseContext,
267) -> AlternateRegistryResolution {
268    let raw_values: HashSet<String> = dependencies
269        .iter()
270        .filter_map(|dep| match &dep.source {
271            DependencySource::CustomRegistry { url } => Some(url.clone()),
272            _ => None,
273        })
274        .collect();
275
276    let mut aliases: HashSet<String> = HashSet::new();
277    // Maps each raw `CustomRegistry.url` value that resolved to its concrete index, so the
278    // rewrite pass below can look a dependency's exact declared value back up.
279    let mut resolved_by_raw_value: HashMap<String, RegistryIndex> = HashMap::new();
280    // Raw values blocked specifically by policy (spec #443/plan-1b §1.7), so the rewrite
281    // pass below can surface an informational diagnostic on the exact dependency line.
282    let mut blocked_by_raw_value: HashMap<String, deps_core::net_policy::HostClass> =
283        HashMap::new();
284    let mut newly_resolved: Vec<(RegistryIndex, Option<AuthToken>)> = Vec::new();
285
286    for value in &raw_values {
287        // A literal `registry-index` URL is workspace-declared by construction — it is a
288        // value written directly into the `Cargo.toml` being parsed. An `InvalidUrl`/
289        // `NotHttps`/`UserInfoPresent` error covers "not a URL at all" (the common case:
290        // `value` is actually an alias, not a literal index) and a genuinely-invalid literal
291        // URL alike — either way, falling through to alias resolution below is safe: an
292        // alias lookup for a URL-shaped string simply won't match any `[registries.*]` entry
293        // and stays unresolved, identical in outcome to failing here directly.
294        match RegistryIndex::new(value, IndexTrust::WorkspaceDeclared, &ctx.policy) {
295            Ok(index) => {
296                resolved_by_raw_value.insert(value.clone(), index.clone());
297                newly_resolved.push((index, None));
298            }
299            Err(RegistryIndexError::BlockedHost { class }) => {
300                blocked_by_raw_value.insert(value.clone(), class);
301            }
302            Err(_) => {
303                aliases.insert(value.clone());
304            }
305        }
306    }
307
308    let cargo_home_path = crate::config::cargo_home_config_path();
309    let (config, source_replacement) = crate::config::resolve(
310        &aliases,
311        workspace_config_paths,
312        cargo_home_path.as_deref(),
313        &ctx.config_cache,
314        &ctx.policy,
315    );
316
317    for alias in &aliases {
318        if let Some(entry) = config.get(alias) {
319            resolved_by_raw_value.insert(alias.clone(), entry.index.clone());
320            newly_resolved.push((entry.index.clone(), entry.auth.clone()));
321        } else if let Some(class) = config.blocked_class(alias) {
322            blocked_by_raw_value.insert(alias.clone(), class);
323        } else {
324            // `alias` here is the raw `registry-index`/`registry` value from the manifest,
325            // not a config-file alias name — it may itself be a URL carrying `user:pass@`
326            // credentials (e.g. `RegistryIndexError::UserInfoPresent` fell through to alias
327            // resolution). Redact before logging (see `deps_core::net_policy::redact_userinfo`).
328            let redacted = deps_core::net_policy::redact_userinfo(alias);
329            tracing::warn!(
330                alias = %redacted,
331                "registry alias did not resolve via the .cargo/config.toml \
332                 hierarchy or $CARGO_HOME/config.toml; dependency stays unresolved"
333            );
334        }
335    }
336
337    // [source] replace-with mirror rewrite (FR-005/006/007): every plain `Registry`
338    // dependency reroutes to the resolved mirror. An alias-based `AlternateRegistry` a
339    // dependency may already carry from the loop above is left untouched — a `[registries]`
340    // alias is not crates.io, so `[source.crates-io]` replacement does not apply to it.
341    if let SourceReplacement::SparseMirror { index, auth } = source_replacement {
342        newly_resolved.push((index.clone(), auth));
343        for dep in dependencies.iter_mut() {
344            if dep.source == DependencySource::Registry {
345                dep.source = DependencySource::AlternateRegistry {
346                    index: index.as_str().to_string(),
347                    mirrors_crates_io: true,
348                };
349            }
350        }
351    }
352
353    let mut blocked_registries = Vec::new();
354    for dep in dependencies.iter_mut() {
355        if let DependencySource::CustomRegistry { url } = &dep.source {
356            if let Some(index) = resolved_by_raw_value.get(url) {
357                dep.source = DependencySource::AlternateRegistry {
358                    index: index.as_str().to_string(),
359                    mirrors_crates_io: false,
360                };
361            } else if let Some(class) = blocked_by_raw_value.get(url) {
362                blocked_registries.push((dep.name_range, *class, url.clone()));
363            }
364        }
365    }
366
367    (newly_resolved, blocked_registries)
368}
369
370/// Parses the `dependencies`, `dev-dependencies`, and `build-dependencies` tables
371/// nested under `table`, extending `dependencies` with what is found.
372///
373/// Shared by the manifest root and by each `[target.<spec>]` table, since both
374/// carry the same three dependency kinds.
375fn parse_dependency_kind_tables(
376    table: &Table<'_>,
377    content: &str,
378    line_table: &LineOffsetTable,
379    dependencies: &mut Vec<ParsedDependency>,
380) {
381    if let Some(deps_val) = get_val(table, "dependencies")
382        && let Some(deps) = deps_val.as_table()
383    {
384        dependencies.extend(parse_dependencies_section(
385            deps,
386            content,
387            line_table,
388            DependencySection::Dependencies,
389        ));
390    }
391
392    if let Some(dev_deps_val) = get_val(table, "dev-dependencies")
393        && let Some(dev_deps) = dev_deps_val.as_table()
394    {
395        dependencies.extend(parse_dependencies_section(
396            dev_deps,
397            content,
398            line_table,
399            DependencySection::DevDependencies,
400        ));
401    }
402
403    if let Some(build_deps_val) = get_val(table, "build-dependencies")
404        && let Some(build_deps) = build_deps_val.as_table()
405    {
406        dependencies.extend(parse_dependencies_section(
407            build_deps,
408            content,
409            line_table,
410            DependencySection::BuildDependencies,
411        ));
412    }
413}
414
415/// Parses a single dependency section (dependencies, dev-dependencies, or build-dependencies).
416fn parse_dependencies_section(
417    table: &Table<'_>,
418    content: &str,
419    line_table: &LineOffsetTable,
420    section: DependencySection,
421) -> Vec<ParsedDependency> {
422    let mut deps = Vec::new();
423
424    for (key, value) in table {
425        let name = key.name.to_string();
426        let name_range = span_to_range(content, line_table, key.span);
427
428        let mut dep = ParsedDependency {
429            name: name.into(),
430            name_range,
431            version_req: None,
432            version_range: None,
433            features: Vec::new(),
434            features_range: None,
435            source: DependencySource::Registry,
436            section,
437        };
438
439        if let Some(s) = value.as_str() {
440            // Simple string version: serde = "1.0"
441            dep.version_req = Some(s.into());
442            dep.version_range = Some(span_to_range(content, line_table, value.span));
443        } else if let Some(t) = value.as_table() {
444            // Inline table or full table: serde = { version = "1.0" }
445            parse_table_dependency(&mut dep, t, content, line_table);
446        } else {
447            continue;
448        }
449
450        deps.push(dep);
451    }
452
453    deps
454}
455
456/// Parses a table (inline or full) dependency entry.
457fn parse_table_dependency(
458    dep: &mut ParsedDependency,
459    table: &Table<'_>,
460    content: &str,
461    line_table: &LineOffsetTable,
462) {
463    // `toml_span::value::Table` is a `BTreeMap` keyed by field name, so it iterates
464    // in alphabetical order (`branch`, `git`, `rev`, `tag`), not TOML source order —
465    // `git` cannot be relied on to run before or after `tag`/`branch`/`rev`. The rev
466    // value is collected here and applied to `dep.source` once the whole table has
467    // been walked, so the result doesn't depend on that ordering (#393).
468    let mut git_rev: Option<String> = None;
469
470    for (key, value) in table {
471        match key.name.as_ref() {
472            "version" => {
473                if let Some(s) = value.as_str() {
474                    dep.version_req = Some(s.into());
475                    dep.version_range = Some(span_to_range(content, line_table, value.span));
476                }
477            }
478            "features" => {
479                if let Some(arr) = value.as_array() {
480                    dep.features = arr
481                        .iter()
482                        .filter_map(|v| v.as_str().map(String::from))
483                        .collect();
484                    dep.features_range = Some(span_to_range(content, line_table, value.span));
485                }
486            }
487            "workspace" if value.as_bool() == Some(true) => {
488                dep.source = DependencySource::Workspace;
489            }
490            "workspace" => {}
491            "git" => {
492                if let Some(url) = value.as_str() {
493                    dep.source = DependencySource::Git {
494                        url: url.to_string(),
495                        rev: None,
496                    };
497                }
498            }
499            // Cargo allows at most one of these per git dependency; take
500            // whichever is present rather than duplicating Cargo's own
501            // validation of that constraint.
502            "tag" | "branch" | "rev" => {
503                if let Some(rev) = value.as_str() {
504                    git_rev = Some(rev.to_string());
505                }
506            }
507            "path" => {
508                if let Some(path) = value.as_str() {
509                    dep.source = DependencySource::Path {
510                        path: path.to_string(),
511                    };
512                }
513            }
514            // `registry = "my-corp"` names an alternative registry defined in
515            // `.cargo/config.toml`, not crates.io. deps-cargo has no client
516            // for it, so it must not stay classified as the plain `Registry`
517            // source: `CustomRegistry` opts it out of version-resolution
518            // diagnostics that would otherwise silently check it against
519            // crates.io's unrelated package of the same name (#248, known
520            // limitation until private-registry client support exists).
521            // `"crates-io"` is Cargo's reserved alias for the *public*
522            // registry (not a custom one) and must stay classified as
523            // `Registry`.
524            "registry" => {
525                if let Some(name) = value.as_str()
526                    && name != "crates-io"
527                {
528                    dep.source = DependencySource::CustomRegistry {
529                        url: name.to_string(),
530                    };
531                }
532            }
533            // `registry-index = "<url>"` is the direct-URL spelling of the
534            // same concept as `registry` above. Cargo's built-in public
535            // index URLs must stay classified as `Registry`; any other URL
536            // names a private index this LSP has no client for.
537            "registry-index" => {
538                if let Some(url) = value.as_str()
539                    && !is_public_crates_io_index(url)
540                {
541                    dep.source = DependencySource::CustomRegistry {
542                        url: url.to_string(),
543                    };
544                }
545            }
546            _ => {}
547        }
548    }
549
550    if let DependencySource::Git { rev, .. } = &mut dep.source {
551        *rev = git_rev;
552    }
553}
554
555/// Returns true if `url` is one of Cargo's built-in public crates.io index URLs
556/// (the git index or the sparse index), as opposed to a private registry index.
557fn is_public_crates_io_index(url: &str) -> bool {
558    matches!(
559        url,
560        "https://github.com/rust-lang/crates.io-index" | "sparse+https://index.crates.io/"
561    )
562}
563
564/// Converts toml-span byte offsets to LSP Range using pre-computed line table.
565fn span_to_range(content: &str, line_table: &LineOffsetTable, span: toml_span::Span) -> Range {
566    let start = line_table.byte_offset_to_position(content, span.start);
567    let end = line_table.byte_offset_to_position(content, span.end);
568    Range::new(start, end)
569}
570
571/// Upper bound on how many ancestor directories [`discover_workspace`] climbs, independent
572/// of whether the filesystem root has been reached (spec NFR-005, plan-1b §1.5, critic N1).
573///
574/// Caps the previously-unbounded workspace-root search — today's manifest walk is unbounded
575/// and TOML-parses every ancestor `Cargo.toml` — and bounds the merged `.cargo/config.toml`
576/// discovery pass added alongside it. A workspace root or config file more than 64
577/// directories up is not a realistic layout; a hostile deeply-nested tree hits this cap
578/// instead of doing unbounded work per parse.
579pub(crate) const MAX_CONFIG_ANCESTOR_DEPTH: usize = 64;
580
581/// Result of [`discover_workspace`]'s merged ancestor walk.
582struct WorkspaceDiscovery {
583    /// The workspace root, if any `[workspace]`-carrying `Cargo.toml` was found within
584    /// [`MAX_CONFIG_ANCESTOR_DEPTH`] — unchanged in meaning from the pre-1b
585    /// `find_workspace_root`, just capped.
586    workspace_root: Option<PathBuf>,
587    /// Every ancestor `.cargo/config.toml` found along the way, closest-first — independent
588    /// of the workspace-root search's own short-circuit (plan-1b §1.5: "only the
589    /// `[workspace]` search short-circuits; config discovery does not").
590    config_paths: Vec<PathBuf>,
591}
592
593/// Finds the workspace root by walking up the directory tree from `doc_uri`'s manifest,
594/// merged with collecting every ancestor `.cargo/config.toml` along the same walk (spec
595/// NFR-005, plan-1b §1.5) — one pass instead of two separate ancestor walks per parse.
596///
597/// The workspace-root search still stops at the first ancestor `Cargo.toml` carrying a
598/// `[workspace]` table (unchanged), but **config-path collection does not stop there**: a
599/// `.cargo/config.toml` above the workspace root (e.g. `~/projects/.cargo/config.toml` over
600/// `~/projects/myrepo/`) is exactly what Cargo itself still consults, and #440 already
601/// shipped that behavior for the alias-resolution path — a naive merge that stopped both
602/// searches at the workspace root would silently regress it (critic N1). Both searches share
603/// [`MAX_CONFIG_ANCESTOR_DEPTH`] as their only stopping bound beyond the filesystem root.
604///
605/// At most two `stat`s per ancestor directory: one for `.cargo/config.toml`'s existence,
606/// and — only while the workspace root is still unresolved — one for `Cargo.toml`'s
607/// existence (plus a read+parse on a hit). Once the workspace root is found, every further
608/// ancestor costs exactly one stat.
609fn discover_workspace(doc_uri: &Uri) -> Result<WorkspaceDiscovery> {
610    let path = doc_uri
611        .to_file_path()
612        .ok_or_else(|| DepsError::InvalidUri(format!("{doc_uri:?}")))?;
613
614    let mut workspace_root = None;
615    let mut config_paths = Vec::new();
616    let mut current = path.parent();
617    let mut depth = 0usize;
618
619    while let Some(dir) = current {
620        if depth >= MAX_CONFIG_ANCESTOR_DEPTH {
621            break;
622        }
623        depth += 1;
624
625        let config_candidate = dir.join(".cargo").join("config.toml");
626        if deps_core::fs_probe::is_file(&config_candidate) {
627            config_paths.push(config_candidate);
628        }
629
630        if workspace_root.is_none() {
631            let workspace_toml = dir.join("Cargo.toml");
632
633            if let Ok(metadata) = deps_core::fs_probe::metadata(&workspace_toml)
634                && metadata.is_file()
635            {
636                if metadata.len() > deps_core::MAX_CACHED_FILE_BYTES {
637                    tracing::warn!(
638                        path = %workspace_toml.display(),
639                        len = metadata.len(),
640                        cap = deps_core::MAX_CACHED_FILE_BYTES,
641                        "skipping ancestor Cargo.toml during workspace root discovery: exceeds size cap"
642                    );
643                } else {
644                    match deps_core::fs_probe::read_to_string_capped(
645                        &workspace_toml,
646                        deps_core::MAX_CACHED_FILE_BYTES,
647                    ) {
648                        Ok(Some(content)) => {
649                            if deps_core::check_toml_nesting_depth(
650                                &content,
651                                deps_core::MAX_TOML_NESTING_DEPTH,
652                            )
653                            .is_err()
654                            {
655                                tracing::warn!(
656                                    path = %workspace_toml.display(),
657                                    "skipping ancestor Cargo.toml during workspace root discovery: nesting depth exceeds maximum"
658                                );
659                            } else if let Ok(doc) = toml_span::parse(&content)
660                                && doc
661                                    .as_table()
662                                    .and_then(|t| get_val(t, "workspace"))
663                                    .is_some()
664                            {
665                                workspace_root = Some(dir.to_path_buf());
666                            }
667                        }
668                        Ok(None) => {
669                            // The stat pre-filter above passed, but the read itself still
670                            // hit the cap — a symlink swap or concurrent growth between the
671                            // two calls (CWE-367). Same outward behavior as the stat-based
672                            // rejection above, just observed later: warn, then skip.
673                            tracing::warn!(
674                                path = %workspace_toml.display(),
675                                cap = deps_core::MAX_CACHED_FILE_BYTES,
676                                "skipping ancestor Cargo.toml during workspace root discovery: exceeds size cap on read"
677                            );
678                        }
679                        Err(_) => {}
680                    }
681                }
682            }
683        }
684
685        current = dir.parent();
686    }
687
688    Ok(WorkspaceDiscovery {
689        workspace_root,
690        config_paths,
691    })
692}
693
694/// Parser for Cargo.toml manifests implementing the deps-core traits.
695pub struct CargoParser;
696
697// Implement new ParseResult trait for trait object support
698impl deps_core::ParseResult for ParseResult {
699    fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
700        self.dependencies
701            .iter()
702            .map(|d| d as &dyn deps_core::Dependency)
703            .collect()
704    }
705
706    fn workspace_root(&self) -> Option<&std::path::Path> {
707        self.workspace_root.as_deref()
708    }
709
710    fn uri(&self) -> &Uri {
711        &self.uri
712    }
713
714    fn blocked_registries(&self) -> Vec<(Range, deps_core::net_policy::HostClass, String)> {
715        self.blocked_registries.clone()
716    }
717
718    fn as_any(&self) -> &dyn Any {
719        self
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    use std::assert_matches;
728
729    fn test_url() -> Uri {
730        #[cfg(windows)]
731        let path = "C:/test/Cargo.toml";
732        #[cfg(not(windows))]
733        let path = "/test/Cargo.toml";
734        Uri::from_file_path(path).unwrap()
735    }
736
737    #[test]
738    fn test_parse_cargo_toml_rejects_excessive_nesting() {
739        // Well past MAX_TOML_NESTING_DEPTH (64) but far below the depth
740        // that would actually overflow the stack, so the guard is what's
741        // being exercised here, not the crash itself.
742        let content = format!("a = {}1{}", "[".repeat(300), "]".repeat(300));
743        let result = parse_cargo_toml(&content, &test_url());
744        assert_matches!(
745            result,
746            Err(DepsError::ParseError { file_type, .. }) if file_type == "Cargo.toml"
747        );
748    }
749
750    #[test]
751    fn test_find_workspace_root_rejects_non_file_uri() {
752        // Empty path (`Uri::to_file_path` returns `None` only when the path is
753        // empty, not merely for a non-file scheme) is what actually drives the
754        // `InvalidUri` branch — this pins that call site to `DepsError::InvalidUri`
755        // rather than the pre-fix `DepsError::CacheError`.
756        let uri: Uri = "https://example.com".parse().unwrap();
757        let result = parse_cargo_toml("[dependencies]\nserde = \"1.0\"", &uri);
758        assert!(
759            matches!(result, Err(DepsError::InvalidUri(_))),
760            "expected InvalidUri, got {result:?}"
761        );
762    }
763
764    #[test]
765    fn test_parse_inline_dependency() {
766        let toml = r#"[dependencies]
767serde = "1.0""#;
768        let result = parse_cargo_toml(toml, &test_url()).unwrap();
769        assert_eq!(result.dependencies.len(), 1);
770        assert_eq!(result.dependencies[0].name, "serde");
771        assert_eq!(result.dependencies[0].version_req, Some("1.0".into()));
772        assert_matches!(result.dependencies[0].source, DependencySource::Registry);
773    }
774
775    #[test]
776    fn test_parse_table_dependency() {
777        let toml = r#"[dependencies]
778serde = { version = "1.0", features = ["derive"] }"#;
779        let result = parse_cargo_toml(toml, &test_url()).unwrap();
780        assert_eq!(result.dependencies.len(), 1);
781        assert_eq!(result.dependencies[0].version_req, Some("1.0".into()));
782        assert_eq!(result.dependencies[0].features, vec!["derive"]);
783    }
784
785    #[test]
786    fn test_parse_workspace_inheritance() {
787        let toml = r"[dependencies]
788serde = { workspace = true }";
789        let result = parse_cargo_toml(toml, &test_url()).unwrap();
790        assert_eq!(result.dependencies.len(), 1);
791        assert_matches!(result.dependencies[0].source, DependencySource::Workspace);
792    }
793
794    #[test]
795    fn test_parse_git_dependency() {
796        let toml = r#"[dependencies]
797tower-lsp = { git = "https://github.com/ebkalderon/tower-lsp", branch = "main" }"#;
798        let result = parse_cargo_toml(toml, &test_url()).unwrap();
799        assert_eq!(result.dependencies.len(), 1);
800        match &result.dependencies[0].source {
801            DependencySource::Git { rev, .. } => assert_eq!(rev.as_deref(), Some("main")),
802            other => panic!("expected Git, got {other:?}"),
803        }
804    }
805
806    #[test]
807    fn test_parse_git_dependency_with_tag() {
808        let toml = r#"[dependencies]
809helix-core = { git = "https://github.com/helix-editor/helix", tag = "25.07.1" }"#;
810        let result = parse_cargo_toml(toml, &test_url()).unwrap();
811        assert_eq!(result.dependencies.len(), 1);
812        match &result.dependencies[0].source {
813            DependencySource::Git { rev, .. } => assert_eq!(rev.as_deref(), Some("25.07.1")),
814            other => panic!("expected Git, got {other:?}"),
815        }
816    }
817
818    #[test]
819    fn test_parse_git_dependency_with_rev() {
820        let toml = r#"[dependencies]
821example = { git = "https://github.com/example/example", rev = "abc123" }"#;
822        let result = parse_cargo_toml(toml, &test_url()).unwrap();
823        assert_eq!(result.dependencies.len(), 1);
824        match &result.dependencies[0].source {
825            DependencySource::Git { rev, .. } => assert_eq!(rev.as_deref(), Some("abc123")),
826            other => panic!("expected Git, got {other:?}"),
827        }
828    }
829
830    #[test]
831    fn test_parse_git_dependency_without_rev_stays_none() {
832        let toml = r#"[dependencies]
833example = { git = "https://github.com/example/example" }"#;
834        let result = parse_cargo_toml(toml, &test_url()).unwrap();
835        assert_eq!(result.dependencies.len(), 1);
836        match &result.dependencies[0].source {
837            DependencySource::Git { rev, .. } => assert!(rev.is_none()),
838            other => panic!("expected Git, got {other:?}"),
839        }
840    }
841
842    #[test]
843    fn test_parse_path_dependency() {
844        let toml = r#"[dependencies]
845local = { path = "../local" }"#;
846        let result = parse_cargo_toml(toml, &test_url()).unwrap();
847        assert_eq!(result.dependencies.len(), 1);
848        assert_matches!(result.dependencies[0].source, DependencySource::Path { .. });
849    }
850
851    #[test]
852    fn test_parse_custom_registry_dependency() {
853        let toml = r#"[dependencies]
854internal-crate = { version = "1.0", registry = "my-corp" }"#;
855        let result = parse_cargo_toml(toml, &test_url()).unwrap();
856        assert_eq!(result.dependencies.len(), 1);
857        match &result.dependencies[0].source {
858            DependencySource::CustomRegistry { url } => assert_eq!(url, "my-corp"),
859            other => panic!("expected CustomRegistry, got {other:?}"),
860        }
861        assert!(!result.dependencies[0].source.is_version_resolvable());
862    }
863
864    #[test]
865    fn test_parse_registry_crates_io_alias_stays_registry() {
866        let toml = r#"[dependencies]
867serde = { version = "1.0", registry = "crates-io" }"#;
868        let result = parse_cargo_toml(toml, &test_url()).unwrap();
869        assert_eq!(result.dependencies.len(), 1);
870        assert_eq!(result.dependencies[0].source, DependencySource::Registry);
871        assert!(result.dependencies[0].source.is_version_resolvable());
872    }
873
874    #[test]
875    fn test_parse_registry_index_custom_url() {
876        // A literal `registry-index` URL is already a concrete, fetchable index — it
877        // resolves to `AlternateRegistry` directly, with no `.cargo/config.toml` lookup
878        // needed (spec FR-002).
879        let toml = r#"[dependencies]
880internal-crate = { version = "1.0", registry-index = "https://gitlab.mycorp.com/registry-index" }"#;
881        let result = parse_cargo_toml(toml, &test_url()).unwrap();
882        assert_eq!(result.dependencies.len(), 1);
883        match &result.dependencies[0].source {
884            DependencySource::AlternateRegistry { index, .. } => {
885                assert_eq!(index, "https://gitlab.mycorp.com/registry-index");
886            }
887            other => panic!("expected AlternateRegistry, got {other:?}"),
888        }
889        assert_eq!(result.resolved_registries.len(), 1);
890        assert!(result.resolved_registries[0].1.is_none());
891    }
892
893    #[test]
894    fn test_parse_registry_index_invalid_url_stays_custom_registry() {
895        // An http:// registry-index URL fails `RegistryIndex` validation, so it must stay
896        // unresolved rather than silently downgrading to an insecure fetch.
897        let toml = r#"[dependencies]
898internal-crate = { version = "1.0", registry-index = "http://insecure.mycorp.com/index" }"#;
899        let result = parse_cargo_toml(toml, &test_url()).unwrap();
900        assert_eq!(result.dependencies.len(), 1);
901        match &result.dependencies[0].source {
902            DependencySource::CustomRegistry { url } => {
903                assert_eq!(url, "http://insecure.mycorp.com/index");
904            }
905            other => panic!("expected CustomRegistry, got {other:?}"),
906        }
907        assert!(result.resolved_registries.is_empty());
908    }
909
910    /// S3 (impl-critic): `ParseResult::blocked_registries` must actually be populated — for a
911    /// literal `registry-index` URL blocked by the default `public_only` policy — carrying
912    /// the dependency's own `name_range` and the raw declared value (so a diagnostic message
913    /// can name it), not just leaving the dependency unresolved with no trace.
914    #[test]
915    fn test_parse_registry_index_literal_blocked_by_policy_populates_blocked_registries() {
916        let toml = r#"[dependencies]
917internal-crate = { version = "1.0", registry-index = "https://169.254.169.254/index" }"#;
918        let ctx = CargoParseContext::default(); // default policy is PublicOnly
919        let result = parse_cargo_toml_with_context(toml, &test_url(), &ctx).unwrap();
920
921        assert_eq!(result.dependencies.len(), 1);
922        assert!(
923            matches!(
924                &result.dependencies[0].source,
925                DependencySource::CustomRegistry { url } if url == "https://169.254.169.254/index"
926            ),
927            "a blocked index must stay unresolved, not silently become AlternateRegistry"
928        );
929        assert_eq!(result.blocked_registries.len(), 1);
930        let (range, class, raw_value) = &result.blocked_registries[0];
931        assert_eq!(*range, result.dependencies[0].name_range);
932        assert_eq!(*class, deps_core::net_policy::HostClass::CloudMetadata);
933        assert_eq!(raw_value, "https://169.254.169.254/index");
934    }
935
936    /// The alias path's `blocked_registries` counterpart: an alias resolving via
937    /// `.cargo/config.toml` to a blocked host must populate the same channel, keyed by the
938    /// alias name (not the resolved URL) since that is what the dependency itself declared.
939    #[test]
940    fn test_parse_custom_registry_alias_blocked_by_policy_populates_blocked_registries() {
941        let root = tempfile::tempdir().unwrap();
942        std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
943        std::fs::write(
944            root.path().join(".cargo/config.toml"),
945            "[registries.my-corp]\nindex = \"https://169.254.169.254\"\n",
946        )
947        .unwrap();
948
949        let manifest_path = root.path().join("Cargo.toml");
950        let manifest_content =
951            "[dependencies]\ninternal-crate = { version = \"1.0\", registry = \"my-corp\" }\n";
952        std::fs::write(&manifest_path, manifest_content).unwrap();
953        let uri = Uri::from_file_path(&manifest_path).unwrap();
954
955        let ctx = CargoParseContext::default();
956        let result = parse_cargo_toml_with_context(manifest_content, &uri, &ctx).unwrap();
957
958        assert_eq!(result.blocked_registries.len(), 1);
959        let (range, class, raw_value) = &result.blocked_registries[0];
960        assert_eq!(*range, result.dependencies[0].name_range);
961        assert_eq!(*class, deps_core::net_policy::HostClass::CloudMetadata);
962        assert_eq!(raw_value, "my-corp");
963    }
964
965    /// #536: a `registry-index` value carrying literal `user:pass@` userinfo fails
966    /// `RegistryIndex::new` with `UserInfoPresent`, so `resolve_alternate_registries` falls
967    /// through to alias resolution (spec: an `InvalidUrl`/`UserInfoPresent` literal is
968    /// treated as a possible `.cargo/config.toml` alias name). When that "alias" then fails
969    /// to resolve too, the unresolved-alias `tracing::warn!` must never log the raw,
970    /// credential-bearing value — it must be redacted first (see
971    /// `deps_core::net_policy::redact_userinfo`), matching the #529 precedent already applied
972    /// to `validate_index_url`'s own error `Display`.
973    #[test]
974    fn test_parse_registry_index_userinfo_alias_fallback_redacts_credential_in_log() {
975        let toml = r#"[dependencies]
976internal-crate = { version = "1.0", registry-index = "sparse+https://user:hunter2@index.crates.io/" }"#;
977
978        let log = deps_core::test_util::capture_tracing_output(|| {
979            let result = parse_cargo_toml(toml, &test_url()).unwrap();
980            assert_eq!(result.dependencies.len(), 1);
981            assert!(
982                matches!(
983                    &result.dependencies[0].source,
984                    DependencySource::CustomRegistry { url }
985                        if url == "sparse+https://user:hunter2@index.crates.io/"
986                ),
987                "a userinfo-bearing index that fails alias resolution must stay unresolved"
988            );
989        });
990
991        assert!(
992            !log.contains("hunter2"),
993            "tracing output leaked the credential: {log:?}"
994        );
995        assert!(
996            !log.contains("user:"),
997            "tracing output leaked the username: {log:?}"
998        );
999        assert!(
1000            log.contains("index.crates.io"),
1001            "host should survive redaction: {log:?}"
1002        );
1003    }
1004
1005    /// #536 C1: two `registry-index` userinfo literals differing only by case (Cargo's
1006    /// env-var naming uppercases the whole alias, so `user:...` and `USER:...` collide on
1007    /// the same `CARGO_REGISTRIES_*_INDEX` name — spec FR-015) fall through to alias
1008    /// resolution and trip `resolve_registries`' env-collision `tracing::warn!`
1009    /// (`config.rs`), which logs the full raw value list. That WARN is a second call site
1010    /// (distinct from the unresolved-alias WARN covered above) that must also redact each
1011    /// entry before logging.
1012    #[test]
1013    fn test_parse_registry_index_env_collision_redacts_credential_in_log() {
1014        let toml = r#"[dependencies]
1015a = { version = "1.0", registry-index = "sparse+https://user:hunter2@index.mycorp.dev/" }
1016b = { version = "1.0", registry-index = "sparse+https://USER:hunter2@index.mycorp.dev/" }"#;
1017
1018        let log = deps_core::test_util::capture_tracing_output(|| {
1019            let result = parse_cargo_toml(toml, &test_url()).unwrap();
1020            assert_eq!(result.dependencies.len(), 2);
1021        });
1022
1023        assert!(
1024            log.contains("two aliases derive the same"),
1025            "expected the env-collision WARN to fire: {log:?}"
1026        );
1027        assert!(
1028            !log.contains("hunter2"),
1029            "tracing output leaked the credential: {log:?}"
1030        );
1031        assert!(
1032            !log.to_lowercase().contains("user:"),
1033            "tracing output leaked the username: {log:?}"
1034        );
1035        assert!(
1036            log.contains("index.mycorp.dev"),
1037            "host should survive redaction: {log:?}"
1038        );
1039    }
1040
1041    #[test]
1042    fn test_parse_custom_registry_alias_unresolved_without_config() {
1043        // No `.cargo/config.toml` exists anywhere above the test fixture path, so the
1044        // alias stays unresolved (spec FR-003) — this is the pre-existing
1045        // `test_parse_custom_registry_dependency` scenario, additionally asserting the
1046        // resolution attempt itself doesn't panic or resolve anything.
1047        let toml = r#"[dependencies]
1048internal-crate = { version = "1.0", registry = "my-corp" }"#;
1049        let result = parse_cargo_toml(toml, &test_url()).unwrap();
1050        assert_eq!(result.dependencies.len(), 1);
1051        match &result.dependencies[0].source {
1052            DependencySource::CustomRegistry { url } => assert_eq!(url, "my-corp"),
1053            other => panic!("expected CustomRegistry, got {other:?}"),
1054        }
1055        assert!(result.resolved_registries.is_empty());
1056    }
1057
1058    #[test]
1059    fn test_parse_custom_registry_alias_resolves_via_workspace_config() {
1060        let root = tempfile::tempdir().unwrap();
1061        std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
1062        std::fs::write(
1063            root.path().join(".cargo/config.toml"),
1064            "[registries.my-corp]\nindex = \"sparse+https://index.mycorp.dev\"\n",
1065        )
1066        .unwrap();
1067
1068        let manifest_path = root.path().join("Cargo.toml");
1069        std::fs::write(&manifest_path, "").unwrap();
1070        let uri = Uri::from_file_path(&manifest_path).unwrap();
1071
1072        let toml = r#"[dependencies]
1073internal-crate = { version = "1.0", registry = "my-corp" }"#;
1074        let result = parse_cargo_toml(toml, &uri).unwrap();
1075        assert_eq!(result.dependencies.len(), 1);
1076        match &result.dependencies[0].source {
1077            DependencySource::AlternateRegistry { index, .. } => {
1078                assert_eq!(index, "https://index.mycorp.dev/");
1079            }
1080            other => panic!("expected AlternateRegistry, got {other:?}"),
1081        }
1082        assert_eq!(result.resolved_registries.len(), 1);
1083        assert!(
1084            result.resolved_registries[0].1.is_none(),
1085            "workspace-sourced entry must never carry a token"
1086        );
1087    }
1088
1089    #[test]
1090    fn test_parse_no_custom_registry_dependency_resolves_nothing() {
1091        // With no CustomRegistry source and no `[source.crates-io] replace-with` chain
1092        // anywhere in scope (no `.cargo/config.toml` exists above the fixture path, and
1093        // `$CARGO_HOME` is either unset or has no such override), nothing resolves. Unlike
1094        // 1a, this is no longer a zero-cost lazy trigger (spec NFR-005's corrected premise
1095        // — `[source]` can affect every plain dependency) — `crate::config::resolve` still
1096        // runs, it just finds nothing to resolve.
1097        let toml = r#"[dependencies]
1098serde = "1.0""#;
1099        let result = parse_cargo_toml(toml, &test_url()).unwrap();
1100        assert!(result.resolved_registries.is_empty());
1101    }
1102
1103    /// FR-005/plan-1b §1.4: a `[source.crates-io] replace-with` chain resolving to a sparse
1104    /// mirror rewrites every plain `Registry` dependency into a resolved, `mirrors_crates_io:
1105    /// true` `AlternateRegistry` — no `registry`/`registry-index` needed on the dependency
1106    /// itself.
1107    #[test]
1108    fn test_parse_plain_dependency_rewritten_via_source_replace_with_mirror() {
1109        let root = tempfile::tempdir().unwrap();
1110        std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
1111        std::fs::write(
1112            root.path().join(".cargo/config.toml"),
1113            "[source.crates-io]\nreplace-with = \"my-mirror\"\n\
1114             [source.my-mirror]\nregistry = \"sparse+https://mirror.corp.example\"\n",
1115        )
1116        .unwrap();
1117
1118        let manifest_content = "[dependencies]\nserde = \"1.0\"\n";
1119        let manifest_path = root.path().join("Cargo.toml");
1120        std::fs::write(&manifest_path, manifest_content).unwrap();
1121        let uri = Uri::from_file_path(&manifest_path).unwrap();
1122
1123        let result = parse_cargo_toml(manifest_content, &uri).unwrap();
1124        assert_eq!(result.dependencies.len(), 1);
1125        match &result.dependencies[0].source {
1126            DependencySource::AlternateRegistry {
1127                index,
1128                mirrors_crates_io,
1129            } => {
1130                assert_eq!(index, "https://mirror.corp.example/");
1131                assert!(*mirrors_crates_io);
1132            }
1133            other => panic!("expected a resolved crates.io mirror, got {other:?}"),
1134        }
1135        assert_eq!(result.resolved_registries.len(), 1);
1136    }
1137
1138    /// FR-006/US-003: a `[source]` replace-with chain terminating at a `directory`
1139    /// (vendored) source must leave plain dependencies unchanged — the pre-1b, crates.io
1140    /// fallback behavior, byte-identical.
1141    #[test]
1142    fn test_parse_plain_dependency_unchanged_when_source_replace_with_is_vendored() {
1143        let root = tempfile::tempdir().unwrap();
1144        std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
1145        std::fs::write(
1146            root.path().join(".cargo/config.toml"),
1147            "[source.crates-io]\nreplace-with = \"vendored\"\n\
1148             [source.vendored]\ndirectory = \"vendor\"\n",
1149        )
1150        .unwrap();
1151
1152        let manifest_content = "[dependencies]\nserde = \"1.0\"\n";
1153        let manifest_path = root.path().join("Cargo.toml");
1154        std::fs::write(&manifest_path, manifest_content).unwrap();
1155        let uri = Uri::from_file_path(&manifest_path).unwrap();
1156
1157        let result = parse_cargo_toml(manifest_content, &uri).unwrap();
1158        assert_eq!(result.dependencies.len(), 1);
1159        assert_eq!(result.dependencies[0].source, DependencySource::Registry);
1160        assert!(result.resolved_registries.is_empty());
1161    }
1162
1163    #[test]
1164    fn test_parse_registry_index_public_crates_io_stays_registry() {
1165        let toml = r#"[dependencies]
1166serde = { version = "1.0", registry-index = "https://github.com/rust-lang/crates.io-index" }
1167serde_json = { version = "1.0", registry-index = "sparse+https://index.crates.io/" }"#;
1168        let result = parse_cargo_toml(toml, &test_url()).unwrap();
1169        assert_eq!(result.dependencies.len(), 2);
1170        for dep in &result.dependencies {
1171            assert_eq!(dep.source, DependencySource::Registry);
1172        }
1173    }
1174
1175    #[test]
1176    fn test_parse_multiple_sections() {
1177        let toml = r#"
1178[dependencies]
1179serde = "1.0"
1180
1181[dev-dependencies]
1182insta = "1.0"
1183
1184[build-dependencies]
1185cc = "1.0"
1186"#;
1187        let result = parse_cargo_toml(toml, &test_url()).unwrap();
1188        assert_eq!(result.dependencies.len(), 3);
1189
1190        assert_matches!(
1191            result.dependencies[0].section,
1192            DependencySection::Dependencies
1193        );
1194        assert_matches!(
1195            result.dependencies[1].section,
1196            DependencySection::DevDependencies
1197        );
1198        assert_matches!(
1199            result.dependencies[2].section,
1200            DependencySection::BuildDependencies
1201        );
1202    }
1203
1204    #[test]
1205    fn test_parse_target_cfg_dependencies() {
1206        let toml = "[target.'cfg(unix)'.dependencies]\nlibc = \"0.2\"";
1207        let result = parse_cargo_toml(toml, &test_url()).unwrap();
1208        assert_eq!(result.dependencies.len(), 1);
1209
1210        let dep = &result.dependencies[0];
1211        assert_eq!(dep.name, "libc");
1212        assert_eq!(dep.version_req, Some("0.2".into()));
1213        assert_eq!(dep.source, DependencySource::Registry);
1214        assert_matches!(dep.section, DependencySection::Dependencies);
1215
1216        // Position must point into the target table, not the (nonexistent) top-level one.
1217        assert_eq!(dep.name_range.start.line, 1);
1218        assert_eq!(dep.name_range.start.character, 0);
1219        assert_eq!(dep.name_range.end.character, 4);
1220    }
1221
1222    #[test]
1223    fn test_parse_target_cfg_dev_dependencies() {
1224        let toml = "[target.'cfg(windows)'.dev-dependencies]\nwinapi = \"0.3\"";
1225        let result = parse_cargo_toml(toml, &test_url()).unwrap();
1226        assert_eq!(result.dependencies.len(), 1);
1227
1228        let dep = &result.dependencies[0];
1229        assert_eq!(dep.name, "winapi");
1230        assert_eq!(dep.version_req, Some("0.3".into()));
1231        assert_matches!(dep.section, DependencySection::DevDependencies);
1232    }
1233
1234    #[test]
1235    fn test_parse_target_triple_build_dependencies() {
1236        let toml = "[target.x86_64-unknown-linux-gnu.build-dependencies]\ncc = \"1.0\"";
1237        let result = parse_cargo_toml(toml, &test_url()).unwrap();
1238        assert_eq!(result.dependencies.len(), 1);
1239
1240        let dep = &result.dependencies[0];
1241        assert_eq!(dep.name, "cc");
1242        assert_eq!(dep.version_req, Some("1.0".into()));
1243        assert_matches!(dep.section, DependencySection::BuildDependencies);
1244    }
1245
1246    #[test]
1247    fn test_parse_target_dependencies_alongside_top_level() {
1248        let toml = r#"
1249[dependencies]
1250serde = "1.0"
1251
1252[target.'cfg(unix)'.dependencies]
1253libc = { version = "0.2", features = ["extra_traits"] }
1254
1255[target.'cfg(windows)'.dev-dependencies]
1256winapi = "0.3"
1257
1258[target.x86_64-unknown-linux-gnu.build-dependencies]
1259cc = "1.0"
1260"#;
1261        let result = parse_cargo_toml(toml, &test_url()).unwrap();
1262        assert_eq!(result.dependencies.len(), 4);
1263
1264        let serde = result
1265            .dependencies
1266            .iter()
1267            .find(|d| d.name == "serde")
1268            .unwrap();
1269        assert_matches!(serde.section, DependencySection::Dependencies);
1270
1271        let libc = result
1272            .dependencies
1273            .iter()
1274            .find(|d| d.name == "libc")
1275            .unwrap();
1276        assert_eq!(libc.version_req, Some("0.2".into()));
1277        assert_eq!(libc.features, vec!["extra_traits"]);
1278        assert_matches!(libc.section, DependencySection::Dependencies);
1279
1280        let winapi = result
1281            .dependencies
1282            .iter()
1283            .find(|d| d.name == "winapi")
1284            .unwrap();
1285        assert_matches!(winapi.section, DependencySection::DevDependencies);
1286
1287        let cc = result.dependencies.iter().find(|d| d.name == "cc").unwrap();
1288        assert_matches!(cc.section, DependencySection::BuildDependencies);
1289    }
1290
1291    #[test]
1292    fn test_line_offset_table() {
1293        let content = "abc\ndef";
1294        let table = LineOffsetTable::new(content);
1295        let pos = table.byte_offset_to_position(content, 4);
1296        assert_eq!(pos.line, 1);
1297        assert_eq!(pos.character, 0);
1298    }
1299
1300    #[test]
1301    fn test_line_offset_table_unicode() {
1302        let content = "hello 世界\nworld";
1303        let table = LineOffsetTable::new(content);
1304        let world_offset = content.find("world").unwrap();
1305        let pos = table.byte_offset_to_position(content, world_offset);
1306        assert_eq!(pos.line, 1);
1307        assert_eq!(pos.character, 0);
1308    }
1309
1310    #[test]
1311    fn test_malformed_toml() {
1312        let toml = r#"[dependencies
1313serde = "1.0"#;
1314        let result = parse_cargo_toml(toml, &test_url());
1315        assert!(result.is_err());
1316    }
1317
1318    #[test]
1319    fn test_empty_dependencies() {
1320        let toml = r"[dependencies]";
1321        let result = parse_cargo_toml(toml, &test_url()).unwrap();
1322        assert_eq!(result.dependencies.len(), 0);
1323    }
1324
1325    #[test]
1326    fn test_position_tracking() {
1327        let toml = r#"[dependencies]
1328serde = "1.0""#;
1329        let result = parse_cargo_toml(toml, &test_url()).unwrap();
1330        let dep = &result.dependencies[0];
1331
1332        assert_eq!(dep.name, "serde");
1333        assert_eq!(dep.version_req, Some("1.0".into()));
1334
1335        // Verify name_range is on line 1 (after [dependencies])
1336        assert_eq!(dep.name_range.start.line, 1);
1337        // serde starts at column 0 on that line
1338        assert_eq!(dep.name_range.start.character, 0);
1339        // Verify end position is after "serde" (5 characters)
1340        assert_eq!(dep.name_range.end.character, 5);
1341    }
1342
1343    #[test]
1344    fn test_name_range_tracking() {
1345        let toml = r#"[dependencies]
1346serde = "1.0"
1347tokio = { version = "1.0", features = ["full"] }"#;
1348        let result = parse_cargo_toml(toml, &test_url()).unwrap();
1349
1350        for dep in &result.dependencies {
1351            // All dependencies should have non-default name ranges
1352            let is_default = dep.name_range.start.line == 0
1353                && dep.name_range.start.character == 0
1354                && dep.name_range.end.line == 0
1355                && dep.name_range.end.character == 0;
1356            assert!(
1357                !is_default,
1358                "name_range should not be default for {}",
1359                dep.name
1360            );
1361        }
1362    }
1363
1364    #[test]
1365    fn test_parse_workspace_dependencies() {
1366        let toml = r#"
1367[workspace]
1368members = ["crates/*"]
1369
1370[workspace.dependencies]
1371serde = "1.0"
1372tokio = { version = "1.0", features = ["full"] }
1373"#;
1374        let result = parse_cargo_toml(toml, &test_url()).unwrap();
1375        assert_eq!(result.dependencies.len(), 2);
1376
1377        for dep in &result.dependencies {
1378            assert_matches!(dep.section, DependencySection::WorkspaceDependencies);
1379        }
1380
1381        let serde = result.dependencies.iter().find(|d| d.name == "serde");
1382        assert!(serde.is_some());
1383        let serde = serde.unwrap();
1384        assert_eq!(serde.version_req, Some("1.0".into()));
1385        // version_range should be set for inlay hints
1386        assert!(
1387            serde.version_range.is_some(),
1388            "version_range should be set for serde"
1389        );
1390
1391        let tokio = result.dependencies.iter().find(|d| d.name == "tokio");
1392        assert!(tokio.is_some());
1393        let tokio = tokio.unwrap();
1394        assert_eq!(tokio.version_req, Some("1.0".into()));
1395        assert_eq!(tokio.features, vec!["full"]);
1396        // version_range should be set for inlay hints
1397        assert!(
1398            tokio.version_range.is_some(),
1399            "version_range should be set for tokio"
1400        );
1401    }
1402
1403    #[test]
1404    fn test_parse_workspace_and_regular_dependencies() {
1405        let toml = r#"
1406[workspace]
1407members = ["crates/*"]
1408
1409[workspace.dependencies]
1410serde = "1.0"
1411
1412[dependencies]
1413tokio = "1.0"
1414"#;
1415        let result = parse_cargo_toml(toml, &test_url()).unwrap();
1416        assert_eq!(result.dependencies.len(), 2);
1417
1418        let serde = result.dependencies.iter().find(|d| d.name == "serde");
1419        assert!(serde.is_some());
1420        assert_matches!(
1421            serde.unwrap().section,
1422            DependencySection::WorkspaceDependencies
1423        );
1424
1425        let tokio = result.dependencies.iter().find(|d| d.name == "tokio");
1426        assert!(tokio.is_some());
1427        assert_matches!(tokio.unwrap().section, DependencySection::Dependencies);
1428    }
1429
1430    #[test]
1431    fn test_find_workspace_root_skips_over_depth_ancestor() {
1432        // Directory layout:
1433        //   <root>/workspace/Cargo.toml        - valid, has [workspace]
1434        //   <root>/workspace/mid/Cargo.toml     - malicious: over MAX_TOML_NESTING_DEPTH
1435        //   <root>/workspace/mid/pkg/Cargo.toml - the file actually opened
1436        // find_workspace_root must skip the malicious ancestor (log + continue)
1437        // rather than failing the whole parse, and still find the valid
1438        // workspace root further up.
1439        let root = tempfile::tempdir().unwrap();
1440        let workspace_dir = root.path().join("workspace");
1441        let mid_dir = workspace_dir.join("mid");
1442        let pkg_dir = mid_dir.join("pkg");
1443        std::fs::create_dir_all(&pkg_dir).unwrap();
1444
1445        std::fs::write(
1446            workspace_dir.join("Cargo.toml"),
1447            "[workspace]\nmembers = [\"mid/pkg\"]\n",
1448        )
1449        .unwrap();
1450
1451        let malicious = format!("a = {}1{}", "[".repeat(300), "]".repeat(300));
1452        std::fs::write(mid_dir.join("Cargo.toml"), malicious).unwrap();
1453
1454        let opened_content = "[dependencies]\nserde = \"1.0\"\n";
1455        let opened_path = pkg_dir.join("Cargo.toml");
1456        std::fs::write(&opened_path, opened_content).unwrap();
1457
1458        let doc_uri = Uri::from_file_path(&opened_path).unwrap();
1459        let result = parse_cargo_toml(opened_content, &doc_uri).unwrap();
1460
1461        assert_eq!(result.dependencies.len(), 1);
1462        assert_eq!(result.workspace_root, Some(workspace_dir));
1463    }
1464
1465    /// N1 regression: the merged ancestor walk must **not** stop collecting
1466    /// `.cargo/config.toml` paths once it finds the workspace root — a config file living
1467    /// *above* the workspace root (e.g. `~/projects/.cargo/config.toml` over
1468    /// `~/projects/myrepo/`) is exactly what Cargo itself still consults, and #440 already
1469    /// shipped that for the alias-resolution path. A naive merge of the workspace-root
1470    /// search with config discovery would silently regress this already-shipped behavior —
1471    /// this is the regression gate for that merge, and it fails against a naive
1472    /// stop-at-workspace-root implementation. The fixture places the config *outside* the
1473    /// tmpdir workspace directory, since a config placed inside the workspace passes either
1474    /// way (naive or correct).
1475    #[test]
1476    fn test_discover_workspace_config_above_workspace_root_still_resolves() {
1477        let root = tempfile::tempdir().unwrap();
1478        let workspace_dir = root.path().join("workspace");
1479        std::fs::create_dir_all(&workspace_dir).unwrap();
1480        std::fs::write(
1481            workspace_dir.join("Cargo.toml"),
1482            "[workspace]\nmembers = [\"pkg\"]\n",
1483        )
1484        .unwrap();
1485
1486        // The config file lives at `root/.cargo/config.toml` — an ancestor of
1487        // `workspace_dir`, but not itself inside it.
1488        std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
1489        std::fs::write(
1490            root.path().join(".cargo/config.toml"),
1491            "[registries.above-root]\nindex = \"sparse+https://above-root.example\"\n",
1492        )
1493        .unwrap();
1494
1495        let pkg_dir = workspace_dir.join("pkg");
1496        std::fs::create_dir_all(&pkg_dir).unwrap();
1497        let manifest_content =
1498            "[dependencies]\ninternal-crate = { version = \"1.0\", registry = \"above-root\" }\n";
1499        let manifest_path = pkg_dir.join("Cargo.toml");
1500        std::fs::write(&manifest_path, manifest_content).unwrap();
1501
1502        let doc_uri = Uri::from_file_path(&manifest_path).unwrap();
1503        let result = parse_cargo_toml(manifest_content, &doc_uri).unwrap();
1504
1505        assert_eq!(result.workspace_root, Some(workspace_dir));
1506        match &result.dependencies[0].source {
1507            DependencySource::AlternateRegistry { index, .. } => {
1508                assert_eq!(index, "https://above-root.example/");
1509            }
1510            other => panic!(
1511                "expected the above-workspace-root config to resolve the alias, got {other:?}"
1512            ),
1513        }
1514    }
1515
1516    /// The ancestor walk stops at [`MAX_CONFIG_ANCESTOR_DEPTH`], for both the workspace-root
1517    /// search and config-path collection — a pathologically deep tree must not do unbounded
1518    /// work per parse.
1519    #[test]
1520    fn test_discover_workspace_stops_at_max_ancestor_depth() {
1521        let root = tempfile::tempdir().unwrap();
1522
1523        // Build a chain deeper than MAX_CONFIG_ANCESTOR_DEPTH, each level carrying its own
1524        // `.cargo/config.toml` with a distinct alias, plus a `[workspace]`-carrying
1525        // `Cargo.toml` at the very top (beyond the cap) that must never be found.
1526        let mut current = root.path().to_path_buf();
1527        for i in 0..(MAX_CONFIG_ANCESTOR_DEPTH + 5) {
1528            current = current.join(format!("d{i}"));
1529        }
1530        std::fs::create_dir_all(&current).unwrap();
1531
1532        // Place the far (unreachable) workspace root and a distinguishing config file at
1533        // the very top of the tree.
1534        std::fs::write(
1535            root.path().join("Cargo.toml"),
1536            "[workspace]\nmembers = [\"*\"]\n",
1537        )
1538        .unwrap();
1539
1540        let opened_content = "[dependencies]\nserde = \"1.0\"\n";
1541        let opened_path = current.join("Cargo.toml");
1542        std::fs::write(&opened_path, opened_content).unwrap();
1543
1544        let doc_uri = Uri::from_file_path(&opened_path).unwrap();
1545        let result = parse_cargo_toml(opened_content, &doc_uri).unwrap();
1546
1547        assert_eq!(
1548            result.workspace_root, None,
1549            "a workspace root beyond MAX_CONFIG_ANCESTOR_DEPTH must not be found"
1550        );
1551    }
1552
1553    /// An ancestor `Cargo.toml` over [`deps_core::MAX_CACHED_FILE_BYTES`] must be skipped
1554    /// during workspace-root discovery via the cheap `stat`-based size pre-filter, which
1555    /// rejects it before `deps_core::fs_probe::read_to_string_capped` is even called — this
1556    /// proves the CWE-400 (uncontrolled resource consumption) rejection path, not the
1557    /// TOCTOU-closing property of the capped read itself (both `read_to_string` and
1558    /// `read_to_string_capped` would pass this test identically, since the pre-filter is
1559    /// what actually stops the read here). The bound on the read call itself is proven
1560    /// independently by `deps_core::fs_probe::tests::read_to_string_capped_rejects_content_over_cap`.
1561    #[test]
1562    fn test_discover_workspace_skips_oversized_ancestor_cargo_toml() {
1563        let root = tempfile::tempdir().unwrap();
1564
1565        // A synthetic chain deeper than MAX_CONFIG_ANCESTOR_DEPTH, so the depth cap always
1566        // stops the walk inside this tempdir — it never escapes into the real filesystem's
1567        // ancestry above it (which could contain an unrelated, readable Cargo.toml and make
1568        // the read-count assertion below flaky).
1569        let mut current = root.path().to_path_buf();
1570        for i in 0..(MAX_CONFIG_ANCESTOR_DEPTH + 5) {
1571            current = current.join(format!("d{i}"));
1572        }
1573        std::fs::create_dir_all(&current).unwrap();
1574
1575        // Sparse file, large enough to exceed the cap without allocating real disk space —
1576        // content is irrelevant, since the size cap must reject it before any open/read.
1577        // Placed two levels up from the opened file, well within the depth budget.
1578        let oversized_manifest = current
1579            .parent()
1580            .unwrap()
1581            .parent()
1582            .unwrap()
1583            .join("Cargo.toml");
1584        let file = std::fs::File::create(&oversized_manifest).unwrap();
1585        file.set_len(deps_core::MAX_CACHED_FILE_BYTES + 1).unwrap();
1586        drop(file);
1587
1588        let opened_content = "[dependencies]\nserde = \"1.0\"\n";
1589        let opened_path = current.join("Cargo.toml");
1590        std::fs::write(&opened_path, opened_content).unwrap();
1591        let doc_uri = Uri::from_file_path(&opened_path).unwrap();
1592
1593        let (_, reads_before) = deps_core::fs_probe::snapshot();
1594        let discovery = discover_workspace(&doc_uri).unwrap();
1595        let (_, reads_after) = deps_core::fs_probe::snapshot();
1596
1597        assert_eq!(
1598            discovery.workspace_root, None,
1599            "an oversized ancestor Cargo.toml must never be treated as the workspace root"
1600        );
1601        assert_eq!(
1602            reads_after - reads_before,
1603            1,
1604            "expected exactly one read: the opened document's own directory Cargo.toml — the \
1605             oversized ancestor two levels up must be rejected by the stat-based pre-filter \
1606             without ever being opened for a read"
1607        );
1608    }
1609
1610    /// P1 (plan-1b §4 Performance/M4, flagged missing by the tester validator): the real
1611    /// bound on the merged ancestor walk is "at most two stats per ancestor directory,
1612    /// capped at MAX_CONFIG_ANCESTOR_DEPTH" — verified here by actually counting `stat`
1613    /// calls (via `deps_core::fs_probe`), not merely asserting the depth cap holds.
1614    /// Uses a purely synthetic chain deeper than the cap, with no `Cargo.toml`/
1615    /// `.cargo/config.toml` anywhere in it, so neither search ever short-circuits before the
1616    /// cap — pinning the count to exactly `2 * MAX_CONFIG_ANCESTOR_DEPTH` regardless of the
1617    /// real filesystem's ancestry above the tempdir.
1618    #[test]
1619    fn test_discover_workspace_stats_at_most_two_per_ancestor() {
1620        let root = tempfile::tempdir().unwrap();
1621        let mut current = root.path().to_path_buf();
1622        for i in 0..(MAX_CONFIG_ANCESTOR_DEPTH + 5) {
1623            current = current.join(format!("d{i}"));
1624        }
1625        std::fs::create_dir_all(&current).unwrap();
1626
1627        let opened_content = "[dependencies]\nserde = \"1.0\"\n";
1628        let opened_path = current.join("Cargo.toml");
1629        std::fs::write(&opened_path, opened_content).unwrap();
1630        let doc_uri = Uri::from_file_path(&opened_path).unwrap();
1631
1632        // Calls `discover_workspace` directly, not `parse_cargo_toml` — this bounds the
1633        // merged ancestor walk itself (parser.rs's own two stat sites), independent of
1634        // `resolve_alternate_registries`'s downstream config resolution, which may add its
1635        // own (unrelated, already-bounded) `$CARGO_HOME/config.toml` stat if the test
1636        // process happens to have a real `CARGO_HOME` set.
1637        let (stats_before, _) = deps_core::fs_probe::snapshot();
1638        let discovery = discover_workspace(&doc_uri).unwrap();
1639        let (stats_after, _) = deps_core::fs_probe::snapshot();
1640
1641        assert_eq!(discovery.workspace_root, None);
1642        assert_eq!(
1643            stats_after - stats_before,
1644            2 * MAX_CONFIG_ANCESTOR_DEPTH,
1645            "expected exactly two stats per ancestor for all MAX_CONFIG_ANCESTOR_DEPTH levels"
1646        );
1647    }
1648}