Skip to main content

cargo_rail/workspace/
context.rs

1//! Unified workspace context - build once, pass everywhere
2//!
3//! # Design
4//!
5//! WorkspaceContext eliminates redundant metadata/config/graph loads by building
6//! all workspace-level data structures once in main.rs, then passing by reference
7//! to all commands.
8//!
9//! # Performance Impact
10//!
11//! Before: Each command loaded metadata/config independently (50-200ms × N commands)
12//! After: Single load in main, shared across all operations (50-200ms total)
13//!
14//! # Architecture
15//!
16//! ```text
17//! main.rs:
18//!   WorkspaceContext::build() -> &WorkspaceContext
19//!   |
20//!   v
21//! commands/split.rs, sync.rs, etc:
22//!   fn execute(ctx: &WorkspaceContext)
23//! ```
24
25use crate::cargo::multi_target_metadata::MultiTargetMetadata;
26use crate::config::{ConfigLoadResult, RailConfig};
27use crate::error::{ConfigError, RailError, RailResult};
28use crate::git::SystemGit;
29use crate::graph::WorkspaceGraph;
30use crate::progress;
31use cargo_metadata::{Metadata, MetadataCommand, Package};
32use serde::{Deserialize, Serialize};
33use std::fs;
34use std::io::Read as _;
35use std::path::{Path, PathBuf};
36use std::sync::{Arc, Mutex};
37
38// Cargo State (merged from cargo_state.rs)
39
40/// Cargo state for the workspace
41///
42/// Provides cargo metadata and workspace information.
43/// This is built once and shared across all commands via WorkspaceContext.
44#[derive(Clone)]
45pub struct CargoState {
46  /// Underlying cargo metadata
47  metadata: Metadata,
48
49  /// Cached workspace root
50  workspace_root: PathBuf,
51
52  /// Index: package name → index in metadata.packages (workspace members only)
53  /// Enables O(1) package lookup instead of O(n) linear scan
54  package_index: std::collections::HashMap<String, usize>,
55
56  /// Cached set of proc-macro crate names for O(1) detection
57  /// Built once during construction, used by change analysis
58  proc_macro_crates: std::collections::HashSet<String>,
59}
60
61/// Cache version - increment when MetadataCache format changes
62/// This ensures old cache files are automatically invalidated
63const CACHE_VERSION: u32 = 2;
64
65/// Metadata cache structure
66#[derive(Serialize, Deserialize)]
67struct MetadataCache {
68  /// Cache format version - used to invalidate on schema changes
69  #[serde(default)]
70  version: u32,
71  /// FNV-1a hash of Cargo.toml + Cargo.lock + all workspace member Cargo.toml manifests
72  hash: u64,
73  /// Cached metadata
74  metadata: Metadata,
75}
76
77impl CargoState {
78  /// Load cargo metadata from workspace root
79  ///
80  /// Uses a content-based cache (FNV-1a hash of workspace Cargo.toml/Cargo.lock + all workspace member Cargo.toml)
81  /// stored in `target/cargo-rail/metadata.json` to speed up subsequent loads.
82  fn load(workspace_root: &Path) -> RailResult<Self> {
83    let cache_dir = workspace_root.join("target").join("cargo-rail");
84    let cache_file = cache_dir.join("metadata.json");
85
86    // Try to load from cache
87    // Cache is valid only if:
88    // 1. Version matches (cache format unchanged)
89    // 2. Hash matches (workspace + member manifests unchanged)
90    // 3. Workspace root path matches (repo hasn't been moved/copied)
91    if let Some(cache) = fs::read_to_string(&cache_file)
92      .ok()
93      .and_then(|s| serde_json::from_str::<MetadataCache>(&s).ok())
94      && cache.version == CACHE_VERSION
95    {
96      // Validate workspace root path matches current location
97      // This handles the case where a repo is copied/moved to a different path
98      let cached_root = cache.metadata.workspace_root.as_std_path();
99      let current_root_canonical = workspace_root
100        .canonicalize()
101        .unwrap_or_else(|_| workspace_root.to_path_buf());
102      let cached_root_canonical = cached_root.canonicalize().unwrap_or_else(|_| cached_root.to_path_buf());
103
104      if current_root_canonical == cached_root_canonical {
105        let current_hash = compute_workspace_hash_with_members(workspace_root, &cache.metadata);
106        if cache.hash == current_hash {
107          // Cache hit - use cached metadata
108          return Ok(Self::from_metadata(cache.metadata));
109        }
110      }
111      // Hash mismatch or path mismatch - cache is stale, will reload below
112    }
113
114    // Cache miss or mismatch - load fresh metadata
115    let metadata = MetadataCommand::new()
116      .manifest_path(workspace_root.join("Cargo.toml"))
117      .exec()?;
118
119    // Save to cache
120    if let Ok(()) = fs::create_dir_all(&cache_dir) {
121      let current_hash = compute_workspace_hash_with_members(workspace_root, &metadata);
122      let cache = MetadataCache {
123        version: CACHE_VERSION,
124        hash: current_hash,
125        metadata: metadata.clone(),
126      };
127      match serde_json::to_string(&cache) {
128        Ok(json) => {
129          if let Err(e) = fs::write(&cache_file, json) {
130            crate::warn!("failed to write metadata cache {}: {}", cache_file.display(), e);
131          }
132        }
133        Err(e) => {
134          crate::warn!(
135            "failed to serialize metadata cache {}: {} (proceeding without cache)",
136            cache_file.display(),
137            e
138          );
139        }
140      }
141    }
142
143    Ok(Self::from_metadata(metadata))
144  }
145
146  /// Build CargoState from metadata, constructing the package index and proc-macro cache
147  fn from_metadata(metadata: Metadata) -> Self {
148    // Normalize path separators on Windows (cargo metadata uses forward slashes).
149    // Using .components().collect() converts to platform-native separators without
150    // adding the \\?\ prefix that canonicalize() would add.
151    let workspace_root: PathBuf = metadata.workspace_root.as_std_path().components().collect();
152
153    // Build O(1) lookup index: package name → index in metadata.packages
154    let workspace_member_ids: std::collections::HashSet<_> = metadata.workspace_members.iter().collect();
155    let workspace_packages: Vec<_> = metadata
156      .packages
157      .iter()
158      .enumerate()
159      .filter(|(_, pkg)| workspace_member_ids.contains(&pkg.id))
160      .collect();
161
162    let package_index = workspace_packages
163      .iter()
164      .map(|(idx, pkg)| (pkg.name.to_string(), *idx))
165      .collect();
166
167    // Build proc-macro crate set for O(1) detection
168    let proc_macro_crates = workspace_packages
169      .iter()
170      .filter(|(_, pkg)| {
171        pkg.targets.iter().any(|t| {
172          t.kind
173            .iter()
174            .any(|k| matches!(k, cargo_metadata::TargetKind::ProcMacro))
175        })
176      })
177      .map(|(_, pkg)| pkg.name.to_string())
178      .collect();
179
180    Self {
181      metadata,
182      workspace_root,
183      package_index,
184      proc_macro_crates,
185    }
186  }
187
188  /// Get workspace root path
189  pub fn workspace_root(&self) -> &Path {
190    &self.workspace_root
191  }
192
193  /// Get all workspace members
194  pub fn workspace_members(&self) -> Vec<&Package> {
195    self.metadata.workspace_packages()
196  }
197
198  /// Get a specific workspace package by name (O(1) lookup)
199  pub fn get_package(&self, name: &str) -> Option<&Package> {
200    self.package_index.get(name).map(|&idx| &self.metadata.packages[idx])
201  }
202
203  /// Get the underlying metadata (for compatibility)
204  pub fn metadata(&self) -> &Metadata {
205    &self.metadata
206  }
207
208  /// Check if a crate is a proc-macro crate (O(1) lookup)
209  pub fn is_proc_macro(&self, crate_name: &str) -> bool {
210    self.proc_macro_crates.contains(crate_name)
211  }
212
213  /// Get the set of all proc-macro crate names
214  pub fn proc_macro_crates(&self) -> &std::collections::HashSet<String> {
215    &self.proc_macro_crates
216  }
217
218  /// Check if a package is publishable based on Cargo.toml
219  ///
220  /// Returns `true` if the package can be published to a registry.
221  /// Returns `false` if `publish = false` in Cargo.toml.
222  ///
223  /// Note: This only checks Cargo.toml. For combined Cargo.toml + rail.toml
224  /// checking, use `ReleaseValidator::is_publishable()`.
225  pub fn is_package_publishable(package: &Package) -> bool {
226    // package.publish is Option<Vec<String>>:
227    // - None = publishable (no restriction)
228    // - Some([]) = publish = false (empty registry list)
229    // - Some(["crates-io"]) = can publish to listed registries
230    package.publish.as_ref().map(|p| !p.is_empty()).unwrap_or(true)
231  }
232
233  /// Check if a workspace member is binary-only (has `[[bin]]` targets but no library target).
234  ///
235  /// This is used by planner/executor flows to optionally skip crates
236  /// that can't be selected by `cargo test -p <crate>` (no library target).
237  pub fn is_binary_only(&self, crate_name: &str) -> bool {
238    let Some(pkg) = self.get_package(crate_name) else {
239      return false;
240    };
241
242    let mut has_bin = false;
243    let mut has_lib_like = false;
244
245    for target in &pkg.targets {
246      for kind in &target.kind {
247        match kind {
248          cargo_metadata::TargetKind::Bin => has_bin = true,
249          cargo_metadata::TargetKind::Lib | cargo_metadata::TargetKind::ProcMacro => has_lib_like = true,
250          _ => {}
251        }
252      }
253    }
254
255    has_bin && !has_lib_like
256  }
257}
258
259/// Compute a content-based hash of workspace manifests
260///
261/// Uses FNV-1a hash of workspace Cargo.toml/Cargo.lock plus all workspace member Cargo.toml
262/// manifests to detect changes that invalidate cached cargo metadata.
263fn compute_workspace_hash_with_members(workspace_root: &Path, metadata: &Metadata) -> u64 {
264  const FNV_PRIME: u64 = 0x100000001b3;
265  const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
266
267  let mut hash = FNV_OFFSET_BASIS;
268
269  fn hash_file(hash: &mut u64, path: &Path, fnv_prime: u64) {
270    let Ok(mut file) = fs::File::open(path) else {
271      // If the file doesn't exist or can't be opened, still mix in a marker so the hash
272      // is very unlikely to match a previous state where it was readable.
273      // This avoids "accidental cache hit" if a file becomes unreadable.
274      *hash ^= 0xff;
275      *hash = hash.wrapping_mul(fnv_prime);
276      return;
277    };
278
279    let mut buffer = [0; 8192];
280    while let Ok(n) = file.read(&mut buffer) {
281      if n == 0 {
282        break;
283      }
284      for byte in &buffer[..n] {
285        *hash ^= *byte as u64;
286        *hash = hash.wrapping_mul(fnv_prime);
287      }
288    }
289  }
290
291  // Hash workspace root Cargo.toml and Cargo.lock (if present)
292  hash_file(&mut hash, &workspace_root.join("Cargo.toml"), FNV_PRIME);
293  hash_file(&mut hash, &workspace_root.join("Cargo.lock"), FNV_PRIME);
294
295  // Hash all workspace member manifests (sorted for determinism)
296  let mut member_manifests: Vec<PathBuf> = metadata
297    .workspace_packages()
298    .iter()
299    .map(|p| p.manifest_path.as_std_path().to_path_buf())
300    .collect();
301
302  member_manifests.sort_unstable_by(|a, b| a.to_string_lossy().cmp(&b.to_string_lossy()));
303
304  for manifest_path in member_manifests {
305    hash_file(&mut hash, &manifest_path, FNV_PRIME);
306  }
307
308  hash
309}
310
311// Git State (merged from git_state.rs)
312
313/// Git state for the workspace
314///
315/// Provides git operations scoped to the workspace repository.
316/// This is built once and shared across all commands via WorkspaceContext.
317#[derive(Clone)]
318pub struct GitState {
319  /// Underlying git backend
320  git: SystemGit,
321
322  /// Cached repository root (git working tree)
323  repo_root: PathBuf,
324}
325
326impl GitState {
327  /// Open git repository at the given path
328  fn open(path: &Path) -> RailResult<Self> {
329    let git = SystemGit::open(path)?;
330    let repo_root = git.worktree_root.clone();
331
332    Ok(Self { git, repo_root })
333  }
334
335  /// Get repository root path
336  pub fn repo_root(&self) -> &Path {
337    &self.repo_root
338  }
339
340  /// Access underlying SystemGit for advanced operations
341  ///
342  /// Use this when you need direct access to SystemGit methods.
343  pub fn git(&self) -> &SystemGit {
344    &self.git
345  }
346
347  /// Get current branch name
348  ///
349  /// Returns "HEAD" if in detached HEAD state.
350  pub fn current_branch(&self) -> RailResult<String> {
351    self.git.current_branch()
352  }
353
354  /// Check if HEAD is detached (not on any branch)
355  pub fn is_detached_head(&self) -> RailResult<bool> {
356    self.git.is_detached_head()
357  }
358
359  /// Get the default branch name (main/master) via remote HEAD
360  ///
361  /// Returns `None` if no default branch can be determined.
362  pub fn default_branch(&self) -> RailResult<Option<String>> {
363    self.git.default_branch()
364  }
365}
366
367// Workspace Context
368
369/// Unified workspace context containing all shared workspace-level data.
370///
371/// Built once at startup, passed by reference to all commands and operations.
372/// This eliminates redundant loads and provides a single source of truth for
373/// workspace state.
374///
375/// Uses Arc for efficient sharing across threads and parallel operations.
376/// Cloning is extremely cheap (just increments Arc refcounts).
377pub struct WorkspaceContext {
378  /// Cargo workspace root (Cargo.toml location)
379  /// Note: In most cases, git repo root == workspace root. Access git root via ctx.git.repo_root() if needed.
380  pub workspace_root: PathBuf,
381
382  /// Git state and operations (Arc for cheap cloning across threads)
383  pub git: Arc<GitState>,
384
385  /// Cargo metadata and workspace info (Arc for cheap cloning across threads)
386  pub cargo: Arc<CargoState>,
387
388  /// Dependency graph (built from cargo metadata)
389  /// Wrapped in Arc for efficient sharing across threads/commands
390  pub graph: Arc<WorkspaceGraph>,
391
392  /// Rail configuration (rail.toml)
393  /// Optional because not all commands require configuration
394  /// Wrapped in Arc for efficient sharing
395  pub config: Option<Arc<RailConfig>>,
396
397  /// Configured targets for lazy multi-target metadata loading
398  targets: Vec<String>,
399
400  /// Lazy-loaded multi-target metadata (only loaded when unify needs it)
401  /// This saves 150-600ms for commands that don't need multi-target analysis.
402  /// Uses Mutex<Option<>> for lazy initialization with error handling.
403  multi_target_metadata: Mutex<Option<Arc<MultiTargetMetadata>>>,
404}
405
406impl WorkspaceContext {
407  /// Build workspace context from a root directory.
408  ///
409  /// Loads git state, cargo metadata, builds graph, and attempts to load rail.toml config.
410  /// Config is optional - commands that require it should check and error.
411  ///
412  /// # Performance
413  ///
414  /// - Git state: <5ms (single git rev-parse)
415  /// - Cargo metadata: 50-200ms for large workspaces
416  /// - Graph build: 10-50ms
417  /// - Config load: <5ms (or None if not found)
418  /// - **Total: ~100-300ms** (vs 100-300ms × N commands without context)
419  pub fn build(workspace_root: &Path) -> RailResult<Self> {
420    // Load git state
421    let git = Arc::new(GitState::open(workspace_root)?);
422
423    // Load cargo state
424    let cargo = Arc::new(CargoState::load(workspace_root)?);
425    let workspace_root = cargo.workspace_root().to_path_buf();
426
427    // Validate git repo root and workspace_root match (or warn if they differ)
428    // Canonicalize both paths to handle Windows short (8.3) vs long path formats
429    let git_root_canonical = git
430      .repo_root()
431      .canonicalize()
432      .unwrap_or_else(|_| git.repo_root().to_path_buf());
433    let workspace_root_canonical = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.clone());
434
435    if git_root_canonical != workspace_root_canonical {
436      crate::warn!(
437        "git repo root ({}) differs from Cargo workspace root ({})",
438        git.repo_root().display(),
439        workspace_root.display()
440      );
441    }
442
443    // Build dependency graph from already-loaded metadata (avoids 50-200ms reload)
444    let graph = Arc::new(WorkspaceGraph::from_metadata(cargo.metadata())?);
445
446    // Load optional config - but ERROR on parse failures
447    // If a config file exists but is broken, that's a user error we must report
448    let config = match RailConfig::try_load(&workspace_root) {
449      ConfigLoadResult::Loaded(cfg) => Some(Arc::new(*cfg)),
450      ConfigLoadResult::NotFound => None,
451      ConfigLoadResult::ParseError { path, message } => {
452        return Err(RailError::Config(ConfigError::ParseError { path, message }));
453      }
454    };
455
456    // Validate configured targets against rustc's canonical target list
457    if let Some(ref cfg) = config
458      && !cfg.targets.is_empty()
459    {
460      crate::targets::validate_targets(&cfg.targets)?;
461    }
462
463    // Validate config settings that require workspace context
464    if let Some(ref cfg) = config {
465      // Validate change-detection glob patterns
466      cfg.change_detection.validate().map_err(RailError::Config)?;
467
468      // Validate unify config (e.g., transitive_host path)
469      cfg.unify.validate(&workspace_root).map_err(RailError::Config)?;
470
471      // Validate run profile schema.
472      cfg.run.validate().map_err(RailError::Config)?;
473    }
474
475    // Store targets for lazy multi-target metadata loading
476    // The metadata is only loaded when unify actually needs it (saves 150-600ms for other commands)
477    let targets = config.as_ref().map(|c| c.targets.clone()).unwrap_or_default();
478
479    Ok(Self {
480      workspace_root,
481      git,
482      cargo,
483      graph,
484      config,
485      targets,
486      multi_target_metadata: Mutex::new(None),
487    })
488  }
489
490  /// Get config or error if not found.
491  ///
492  /// Use this in commands that require rail.toml configuration.
493  pub fn require_config(&self) -> RailResult<&Arc<RailConfig>> {
494    self.config.as_ref().ok_or_else(|| {
495      crate::error::RailError::message(format!(
496        "No rail.toml found in: {}\nSearched: rail.toml, .rail.toml, .cargo/rail.toml, .config/rail.toml\nRun 'cargo rail init' to create one.",
497        self.workspace_root.display()
498      ))
499    })
500  }
501
502  /// Get multi-target metadata, loading lazily on first access.
503  ///
504  /// This is only used by `cargo rail unify`. Other commands don't need it,
505  /// so lazy loading saves 150-600ms for commands like `plan`, `run`, `split`, etc.
506  ///
507  /// The metadata is cached after first load - subsequent calls return the cached value.
508  pub fn multi_target_metadata(&self) -> RailResult<Arc<MultiTargetMetadata>> {
509    // Lock briefly to check if already loaded
510    let mut guard = self
511      .multi_target_metadata
512      .lock()
513      .map_err(|_| RailError::message("Lock poisoned".to_string()))?;
514
515    if let Some(ref cached) = *guard {
516      return Ok(Arc::clone(cached));
517    }
518
519    // Not loaded yet - load now
520    if !self.targets.is_empty() {
521      progress!("Loading metadata for {} target(s)...", self.targets.len());
522    }
523    let metadata = Arc::new(MultiTargetMetadata::load_parallel(&self.workspace_root, &self.targets)?);
524    *guard = Some(Arc::clone(&metadata));
525    Ok(metadata)
526  }
527
528  /// Get workspace root as Path reference (convenience)
529  pub fn workspace_root(&self) -> &Path {
530    &self.workspace_root
531  }
532
533  /// Get the relative path from git root to workspace root.
534  ///
535  /// Returns `Some(prefix)` if workspace is nested inside git repo (e.g., "codex-rs"),
536  /// or `None` if they're the same directory.
537  ///
538  /// This is used to strip the prefix from git-relative paths so they can be
539  /// matched against workspace-relative paths (e.g., for crate membership).
540  pub fn workspace_prefix(&self) -> Option<PathBuf> {
541    let git_root = self.git.repo_root();
542
543    // On Windows, paths from different sources may have incompatible representations:
544    // - Forward vs backslash separators (C:/foo vs C:\foo)
545    // - 8.3 short names vs long names (RUNNER~1 vs runneradmin)
546    // - Case differences (on case-insensitive filesystems)
547    //
548    // We must canonicalize both paths to get a consistent representation.
549    // Note: canonicalize() adds \\?\ prefix on Windows, but strip_prefix handles this
550    // correctly when both paths have the same prefix.
551    let git_root_canonical = git_root.canonicalize().unwrap_or_else(|_| git_root.to_path_buf());
552    let workspace_canonical = self
553      .workspace_root
554      .canonicalize()
555      .unwrap_or_else(|_| self.workspace_root.clone());
556
557    // If workspace is nested inside git repo, compute the relative prefix
558    if let Ok(prefix) = workspace_canonical.strip_prefix(&git_root_canonical) {
559      if prefix.as_os_str().is_empty() {
560        None // Same directory
561      } else {
562        Some(prefix.to_path_buf())
563      }
564    } else {
565      None
566    }
567  }
568
569  /// Convert a git-relative path to a workspace-relative path.
570  ///
571  /// If the workspace is nested inside the git repo (e.g., git at `/repo`, workspace at `/repo/rust`),
572  /// git returns paths like `rust/src/lib.rs` but the workspace expects `src/lib.rs`.
573  ///
574  /// Returns `None` if the path doesn't belong to this workspace.
575  pub fn to_workspace_path(&self, git_path: &Path) -> Option<PathBuf> {
576    if let Some(prefix) = self.workspace_prefix() {
577      // Git always uses forward slashes, but PathBuf::from converts to platform separators.
578      // On Windows, this causes strip_prefix to fail when git_path has / but prefix has \.
579      // Normalize git_path by rebuilding through components to use platform separators.
580      let normalized = git_path.components().collect::<PathBuf>();
581      normalized.strip_prefix(&prefix).ok().map(|p| p.to_path_buf())
582    } else {
583      Some(git_path.to_path_buf())
584    }
585  }
586}
587
588#[cfg(test)]
589mod tests {
590  use super::*;
591
592  #[test]
593  fn test_workspace_context_build() {
594    // Use current directory as test workspace
595    let current_dir = std::env::current_dir().unwrap();
596
597    // Should successfully build workspace context
598    let ctx = WorkspaceContext::build(&current_dir);
599    assert!(ctx.is_ok(), "Should successfully build workspace context");
600
601    let ctx = ctx.unwrap();
602
603    // Verify all components are initialized
604    assert!(ctx.git.repo_root().exists(), "Repo root should exist");
605    assert!(ctx.workspace_root.exists(), "Workspace root should exist");
606
607    // Git state should be initialized (git root typically == workspace root)
608    // We allow them to differ but in most cases they're the same
609    let _ = ctx.git.repo_root();
610
611    // Cargo state should be initialized
612    assert_eq!(
613      ctx.cargo.workspace_root(),
614      &ctx.workspace_root,
615      "Cargo workspace root should match"
616    );
617
618    // Should find cargo-rail in workspace
619    let packages = ctx.cargo.workspace_members();
620    assert!(!packages.is_empty(), "Should have workspace packages");
621    assert!(
622      packages.iter().any(|p| p.name == "cargo-rail"),
623      "Should find cargo-rail package"
624    );
625
626    // Graph should be initialized
627    let members = ctx.graph.workspace_members();
628    assert!(!members.is_empty(), "Graph should have workspace members");
629    assert!(
630      members.contains(&"cargo-rail".to_string()),
631      "Graph should contain cargo-rail"
632    );
633
634    // Config may or may not be loaded depending on workspace
635    // Just verify it's an Option
636    let _ = ctx.config.as_ref();
637  }
638
639  #[test]
640  fn test_git_state_wrapper() {
641    let current_dir = std::env::current_dir().unwrap();
642    let ctx = WorkspaceContext::build(&current_dir).unwrap();
643
644    // Should be able to access git operations via git() accessor
645    let head = ctx.git.git().head_commit();
646    assert!(head.is_ok(), "Should get HEAD commit");
647
648    let head_sha = head.unwrap();
649    assert_eq!(head_sha.len(), 40, "HEAD SHA should be 40 characters");
650
651    // Should be able to get current branch
652    let branch = ctx.git.git().current_branch();
653    assert!(branch.is_ok(), "Should get current branch");
654  }
655
656  #[test]
657  fn test_cargo_state_wrapper() {
658    let current_dir = std::env::current_dir().unwrap();
659    let ctx = WorkspaceContext::build(&current_dir).unwrap();
660
661    // Should be able to access cargo metadata
662    let metadata = ctx.cargo.metadata();
663    let packages = metadata.workspace_packages();
664    assert!(!packages.is_empty(), "Should have packages");
665
666    // Should be able to get specific package
667    let cargo_rail = ctx.cargo.get_package("cargo-rail");
668    assert!(cargo_rail.is_some(), "Should find cargo-rail package");
669
670    let pkg = cargo_rail.unwrap();
671    assert_eq!(pkg.name.as_str(), "cargo-rail");
672  }
673
674  #[test]
675  fn test_graph_integration() {
676    let current_dir = std::env::current_dir().unwrap();
677    let ctx = WorkspaceContext::build(&current_dir).unwrap();
678
679    // Graph should be consistent with cargo metadata
680    let graph_members = ctx.graph.workspace_members();
681    let cargo_packages: Vec<_> = ctx.cargo.workspace_members().iter().map(|p| p.name.as_str()).collect();
682
683    for member in graph_members {
684      assert!(
685        cargo_packages.contains(&member.as_str()),
686        "Graph member {} should be in cargo packages",
687        member
688      );
689    }
690  }
691}