1use 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#[derive(Clone)]
45pub struct CargoState {
46 metadata: Metadata,
48
49 workspace_root: PathBuf,
51
52 package_index: std::collections::HashMap<String, usize>,
55
56 proc_macro_crates: std::collections::HashSet<String>,
59}
60
61const CACHE_VERSION: u32 = 2;
64
65#[derive(Serialize, Deserialize)]
67struct MetadataCache {
68 #[serde(default)]
70 version: u32,
71 hash: u64,
73 metadata: Metadata,
75}
76
77impl CargoState {
78 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 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 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 return Ok(Self::from_metadata(cache.metadata));
109 }
110 }
111 }
113
114 let metadata = MetadataCommand::new()
116 .manifest_path(workspace_root.join("Cargo.toml"))
117 .exec()?;
118
119 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 fn from_metadata(metadata: Metadata) -> Self {
148 let workspace_root: PathBuf = metadata.workspace_root.as_std_path().components().collect();
152
153 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 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 pub fn workspace_root(&self) -> &Path {
190 &self.workspace_root
191 }
192
193 pub fn workspace_members(&self) -> Vec<&Package> {
195 self.metadata.workspace_packages()
196 }
197
198 pub fn get_package(&self, name: &str) -> Option<&Package> {
200 self.package_index.get(name).map(|&idx| &self.metadata.packages[idx])
201 }
202
203 pub fn metadata(&self) -> &Metadata {
205 &self.metadata
206 }
207
208 pub fn is_proc_macro(&self, crate_name: &str) -> bool {
210 self.proc_macro_crates.contains(crate_name)
211 }
212
213 pub fn proc_macro_crates(&self) -> &std::collections::HashSet<String> {
215 &self.proc_macro_crates
216 }
217
218 pub fn is_package_publishable(package: &Package) -> bool {
226 package.publish.as_ref().map(|p| !p.is_empty()).unwrap_or(true)
231 }
232
233 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
259fn 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 *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_file(&mut hash, &workspace_root.join("Cargo.toml"), FNV_PRIME);
293 hash_file(&mut hash, &workspace_root.join("Cargo.lock"), FNV_PRIME);
294
295 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#[derive(Clone)]
318pub struct GitState {
319 git: SystemGit,
321
322 repo_root: PathBuf,
324}
325
326impl GitState {
327 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 pub fn repo_root(&self) -> &Path {
337 &self.repo_root
338 }
339
340 pub fn git(&self) -> &SystemGit {
344 &self.git
345 }
346
347 pub fn current_branch(&self) -> RailResult<String> {
351 self.git.current_branch()
352 }
353
354 pub fn is_detached_head(&self) -> RailResult<bool> {
356 self.git.is_detached_head()
357 }
358
359 pub fn default_branch(&self) -> RailResult<Option<String>> {
363 self.git.default_branch()
364 }
365}
366
367pub struct WorkspaceContext {
378 pub workspace_root: PathBuf,
381
382 pub git: Arc<GitState>,
384
385 pub cargo: Arc<CargoState>,
387
388 pub graph: Arc<WorkspaceGraph>,
391
392 pub config: Option<Arc<RailConfig>>,
396
397 targets: Vec<String>,
399
400 multi_target_metadata: Mutex<Option<Arc<MultiTargetMetadata>>>,
404}
405
406impl WorkspaceContext {
407 pub fn build(workspace_root: &Path) -> RailResult<Self> {
420 let git = Arc::new(GitState::open(workspace_root)?);
422
423 let cargo = Arc::new(CargoState::load(workspace_root)?);
425 let workspace_root = cargo.workspace_root().to_path_buf();
426
427 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 let graph = Arc::new(WorkspaceGraph::from_metadata(cargo.metadata())?);
445
446 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 if let Some(ref cfg) = config
458 && !cfg.targets.is_empty()
459 {
460 crate::targets::validate_targets(&cfg.targets)?;
461 }
462
463 if let Some(ref cfg) = config {
465 cfg.change_detection.validate().map_err(RailError::Config)?;
467
468 cfg.unify.validate(&workspace_root).map_err(RailError::Config)?;
470
471 cfg.run.validate().map_err(RailError::Config)?;
473 }
474
475 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 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 pub fn multi_target_metadata(&self) -> RailResult<Arc<MultiTargetMetadata>> {
509 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 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 pub fn workspace_root(&self) -> &Path {
530 &self.workspace_root
531 }
532
533 pub fn workspace_prefix(&self) -> Option<PathBuf> {
541 let git_root = self.git.repo_root();
542
543 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 let Ok(prefix) = workspace_canonical.strip_prefix(&git_root_canonical) {
559 if prefix.as_os_str().is_empty() {
560 None } else {
562 Some(prefix.to_path_buf())
563 }
564 } else {
565 None
566 }
567 }
568
569 pub fn to_workspace_path(&self, git_path: &Path) -> Option<PathBuf> {
576 if let Some(prefix) = self.workspace_prefix() {
577 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 let current_dir = std::env::current_dir().unwrap();
596
597 let ctx = WorkspaceContext::build(¤t_dir);
599 assert!(ctx.is_ok(), "Should successfully build workspace context");
600
601 let ctx = ctx.unwrap();
602
603 assert!(ctx.git.repo_root().exists(), "Repo root should exist");
605 assert!(ctx.workspace_root.exists(), "Workspace root should exist");
606
607 let _ = ctx.git.repo_root();
610
611 assert_eq!(
613 ctx.cargo.workspace_root(),
614 &ctx.workspace_root,
615 "Cargo workspace root should match"
616 );
617
618 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 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 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(¤t_dir).unwrap();
643
644 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 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(¤t_dir).unwrap();
660
661 let metadata = ctx.cargo.metadata();
663 let packages = metadata.workspace_packages();
664 assert!(!packages.is_empty(), "Should have packages");
665
666 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(¤t_dir).unwrap();
678
679 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}