Skip to content

Commit 030d3f2

Browse files
committed
cargo-rail: updating the workspace graph to include 'visibility'; added the 'quality' engine.
1 parent f48c97e commit 030d3f2

File tree

11 files changed

+1084
-4
lines changed

11 files changed

+1084
-4
lines changed

src/checks/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ mod remotes;
4141
mod runner;
4242
mod security_config;
4343
mod ssh;
44+
mod tier_violations;
4445
mod trait_def;
4546
mod workspace;
4647

src/checks/runner.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ pub fn create_default_runner() -> CheckRunner {
8282
runner.add_check(Arc::new(super::edition_consistency::EditionConsistencyCheck));
8383
runner.add_check(Arc::new(super::msrv::MSRVCheck));
8484
runner.add_check(Arc::new(super::patch_replace::PatchReplaceCheck));
85+
runner.add_check(Arc::new(super::tier_violations::TierViolationCheck));
8586
runner.add_check(Arc::new(super::ssh::SshKeyCheck));
8687
runner.add_check(Arc::new(super::security_config::SecurityConfigCheck));
8788
runner.add_check(Arc::new(super::git_notes::GitNotesCheck));
@@ -99,6 +100,7 @@ pub fn create_manifest_runner() -> CheckRunner {
99100
runner.add_check(Arc::new(super::edition_consistency::EditionConsistencyCheck));
100101
runner.add_check(Arc::new(super::msrv::MSRVCheck));
101102
runner.add_check(Arc::new(super::patch_replace::PatchReplaceCheck));
103+
runner.add_check(Arc::new(super::tier_violations::TierViolationCheck));
102104

103105
runner
104106
}

src/checks/tier_violations.rs

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
//! Tier violation checks for visibility-based release tiers
2+
//!
3+
//! Detects violations where lower-tier releases (e.g., OSS) depend on
4+
//! higher-tier crates (e.g., enterprise), which would break the release.
5+
6+
use crate::checks::trait_def::{Check, CheckContext, CheckResult, Severity};
7+
use crate::core::config::{RailConfig, Visibility};
8+
use crate::core::error::RailResult;
9+
use crate::graph::workspace_graph::WorkspaceGraph;
10+
use std::collections::HashSet;
11+
12+
/// Check for tier violations in release configurations.
13+
///
14+
/// A tier violation occurs when:
15+
/// - An OSS release includes or depends on an internal/enterprise crate
16+
/// - An internal release depends on an enterprise crate
17+
///
18+
/// This ensures releases can be published without exposing higher-tier code.
19+
pub struct TierViolationCheck;
20+
21+
impl Check for TierViolationCheck {
22+
fn name(&self) -> &str {
23+
"tier-violations"
24+
}
25+
26+
fn description(&self) -> &str {
27+
"Checks for tier violations (OSS depending on internal/enterprise)"
28+
}
29+
30+
fn run(&self, ctx: &CheckContext) -> RailResult<CheckResult> {
31+
// Load config
32+
let config = match RailConfig::load(&ctx.workspace_root) {
33+
Ok(c) => c,
34+
Err(_) => {
35+
// No config = no releases = no tier violations
36+
return Ok(CheckResult {
37+
check_name: self.name().to_string(),
38+
passed: true,
39+
message: "No rail.toml found (tier checks require releases config)".to_string(),
40+
suggestion: None,
41+
severity: Severity::Info,
42+
details: None,
43+
});
44+
}
45+
};
46+
47+
// If no releases configured, skip check
48+
if config.releases.is_empty() {
49+
return Ok(CheckResult {
50+
check_name: self.name().to_string(),
51+
passed: true,
52+
message: "No releases configured (tier checks require releases)".to_string(),
53+
suggestion: None,
54+
severity: Severity::Info,
55+
details: None,
56+
});
57+
}
58+
59+
// Load graph with config to get visibility annotations
60+
let graph = WorkspaceGraph::load_with_config(&ctx.workspace_root, Some(&config))?;
61+
62+
let mut violations = Vec::new();
63+
64+
// Check each release for tier violations
65+
for release in &config.releases {
66+
// Get the primary crate for this release
67+
let primary_crate = release
68+
.crate_path
69+
.file_name()
70+
.and_then(|n| n.to_str())
71+
.unwrap_or(&release.name);
72+
73+
// Collect all crates in this release (primary + includes)
74+
let mut release_crates = HashSet::new();
75+
release_crates.insert(primary_crate.to_string());
76+
for included in &release.includes {
77+
release_crates.insert(included.clone());
78+
}
79+
80+
// For each crate in the release, check its dependencies
81+
for crate_name in &release_crates {
82+
// Get all transitive dependencies (what this crate depends on)
83+
let mut to_check = vec![crate_name.clone()];
84+
let mut checked = HashSet::new();
85+
let mut all_deps = HashSet::new();
86+
87+
while let Some(current) = to_check.pop() {
88+
if checked.contains(&current) {
89+
continue;
90+
}
91+
checked.insert(current.clone());
92+
93+
// Get direct dependencies
94+
if let Ok(deps) = graph.direct_dependencies(&current) {
95+
for dep in deps {
96+
if !all_deps.contains(&dep) && graph.workspace_members().contains(&dep) {
97+
all_deps.insert(dep.clone());
98+
to_check.push(dep);
99+
}
100+
}
101+
}
102+
}
103+
104+
// Check each transitive dependency's visibility
105+
for dep in all_deps {
106+
let dep_visibilities = graph.crate_visibilities(&dep);
107+
108+
// Skip if dependency has no visibility (not in any release)
109+
if dep_visibilities.is_empty() {
110+
continue;
111+
}
112+
113+
// Skip self-references
114+
if dep == *crate_name {
115+
continue;
116+
}
117+
118+
// Check for violations based on release visibility
119+
match release.visibility {
120+
Visibility::Oss => {
121+
// OSS can't depend on internal or enterprise
122+
if dep_visibilities.contains(&Visibility::Internal) || dep_visibilities.contains(&Visibility::Enterprise)
123+
{
124+
violations.push(format!(
125+
"Release '{}' (OSS) includes '{}' which depends on '{}' ({:?})",
126+
release.name, crate_name, dep, dep_visibilities
127+
));
128+
}
129+
}
130+
Visibility::Internal => {
131+
// Internal can't depend on enterprise
132+
if dep_visibilities.contains(&Visibility::Enterprise) {
133+
violations.push(format!(
134+
"Release '{}' (internal) includes '{}' which depends on '{}' (enterprise)",
135+
release.name, crate_name, dep
136+
));
137+
}
138+
}
139+
Visibility::Enterprise => {
140+
// Enterprise can depend on anything
141+
}
142+
}
143+
}
144+
}
145+
}
146+
147+
if violations.is_empty() {
148+
Ok(CheckResult {
149+
check_name: self.name().to_string(),
150+
passed: true,
151+
message: "No tier violations found".to_string(),
152+
suggestion: None,
153+
severity: Severity::Info,
154+
details: None,
155+
})
156+
} else {
157+
Ok(CheckResult {
158+
check_name: self.name().to_string(),
159+
passed: false,
160+
message: format!(
161+
"Found {} tier violation(s):\n - {}",
162+
violations.len(),
163+
violations.join("\n - ")
164+
),
165+
suggestion: Some(
166+
"Review release configurations and remove higher-tier dependencies, or adjust release visibility".to_string(),
167+
),
168+
severity: Severity::Error,
169+
details: None,
170+
})
171+
}
172+
}
173+
}
174+
175+
#[cfg(test)]
176+
mod tests {
177+
use super::*;
178+
179+
#[test]
180+
fn test_check_name() {
181+
let check = TierViolationCheck;
182+
assert_eq!(check.name(), "tier-violations");
183+
}
184+
185+
#[test]
186+
fn test_check_without_config() {
187+
use std::env;
188+
189+
let temp_dir = env::temp_dir().join("cargo-rail-test-tier-no-config");
190+
let _ = std::fs::remove_dir_all(&temp_dir);
191+
std::fs::create_dir_all(&temp_dir).unwrap();
192+
193+
let ctx = CheckContext {
194+
workspace_root: temp_dir.clone(),
195+
crate_name: None,
196+
thorough: false,
197+
};
198+
199+
let check = TierViolationCheck;
200+
let result = check.run(&ctx).unwrap();
201+
202+
assert!(result.passed);
203+
assert!(result.message.contains("No rail.toml"));
204+
205+
let _ = std::fs::remove_dir_all(&temp_dir);
206+
}
207+
}

src/commands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ pub mod doctor;
3333
pub mod init;
3434
pub mod lint;
3535
pub mod mappings;
36+
pub mod quality;
3637
pub mod release;
3738
pub mod split;
3839
pub mod status;
@@ -46,6 +47,7 @@ pub use doctor::run_doctor;
4647
pub use init::run_init;
4748
pub use lint::{run_lint_deps, run_lint_manifest, run_lint_versions};
4849
pub use mappings::run_mappings;
50+
pub use quality::{apply_fixes, run_quality};
4951
pub use release::{run_release_apply, run_release_plan};
5052
pub use split::run_split;
5153
pub use status::run_status;

src/commands/quality.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
//! Quality analysis command
2+
//!
3+
//! Runs unified quality analyses over the workspace.
4+
5+
use crate::core::config::RailConfig;
6+
use crate::core::context::WorkspaceContext;
7+
use crate::core::error::{ExitCode, RailError, RailResult};
8+
use crate::graph::workspace_graph::WorkspaceGraph;
9+
use crate::quality::{QualityContext, create_default_engine};
10+
11+
/// Run quality analysis
12+
pub fn run_quality(ctx: &WorkspaceContext, json: bool, analysis: Option<String>) -> RailResult<()> {
13+
// Load config
14+
let config = RailConfig::load(ctx.workspace_root())?;
15+
16+
// Build graph with config for visibility annotations
17+
let graph = WorkspaceGraph::load_with_config(ctx.workspace_root(), Some(&config))?;
18+
19+
// Create quality context
20+
let quality_ctx = QualityContext::new(ctx, &graph, &config);
21+
22+
// Create engine with all analyses
23+
let engine = create_default_engine();
24+
25+
// Run analyses
26+
let report = if let Some(name) = analysis {
27+
// Run specific analysis
28+
if let Some(result) = engine.run_one(&quality_ctx, &name)? {
29+
crate::quality::QualityReport { results: vec![result] }
30+
} else {
31+
return Err(RailError::with_help(
32+
format!("Unknown analysis: {}", name),
33+
format!(
34+
"Available analyses: {}",
35+
engine
36+
.analyses()
37+
.iter()
38+
.map(|a| a.name())
39+
.collect::<Vec<_>>()
40+
.join(", ")
41+
),
42+
));
43+
}
44+
} else {
45+
// Run all analyses
46+
engine.run_all(&quality_ctx)?
47+
};
48+
49+
// Output results
50+
if json {
51+
println!("{}", report.to_json()?);
52+
} else {
53+
print_human_readable(&report, &engine);
54+
}
55+
56+
// Exit with appropriate code
57+
if !report.passed() {
58+
std::process::exit(ExitCode::Validation.as_i32());
59+
}
60+
61+
Ok(())
62+
}
63+
64+
/// Print human-readable quality report
65+
fn print_human_readable(report: &crate::quality::QualityReport, engine: &crate::quality::QualityEngine) {
66+
println!("🔍 Running quality analyses...\n");
67+
68+
// Show registered analyses
69+
println!("📋 Registered analyses:");
70+
for analysis in engine.analyses() {
71+
println!(" • {}: {}", analysis.name(), analysis.description());
72+
}
73+
println!();
74+
75+
// Show results
76+
for result in &report.results {
77+
let icon = if result.passed { "✅" } else { "❌" };
78+
println!("{} {}", icon, result.analysis);
79+
80+
if !result.violations.is_empty() {
81+
for violation in &result.violations {
82+
let severity_icon = match violation.severity {
83+
crate::quality::Severity::Error => "❌",
84+
crate::quality::Severity::Warning => "⚠️ ",
85+
crate::quality::Severity::Info => "ℹ️ ",
86+
};
87+
88+
println!(" {} [{}] {}", severity_icon, violation.location, violation.message);
89+
90+
if let Some(ref suggestion) = violation.suggestion {
91+
println!(" 💡 {}", suggestion);
92+
}
93+
}
94+
}
95+
println!();
96+
}
97+
98+
// Summary
99+
let (errors, warnings, info) = report.count_violations();
100+
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
101+
println!("Summary: {} errors, {} warnings, {} info", errors, warnings, info);
102+
103+
if !report.passed() {
104+
println!("\n⚠️ Quality checks failed. Please address the violations above.");
105+
} else if warnings > 0 {
106+
println!("\n⚠️ Some warnings found. Consider addressing them.");
107+
} else {
108+
println!("\n✨ All quality checks passed!");
109+
}
110+
}
111+
112+
/// Apply auto-fixes for quality violations
113+
pub fn apply_fixes(ctx: &WorkspaceContext, analysis_name: &str) -> RailResult<()> {
114+
// Load config
115+
let config = RailConfig::load(ctx.workspace_root())?;
116+
117+
// Build graph
118+
let graph = WorkspaceGraph::load_with_config(ctx.workspace_root(), Some(&config))?;
119+
120+
// Create quality context
121+
let quality_ctx = QualityContext::new(ctx, &graph, &config);
122+
123+
// Create engine
124+
let engine = create_default_engine();
125+
126+
// Apply fixes
127+
let fixed_count = engine.apply_fixes(&quality_ctx, analysis_name)?;
128+
129+
if fixed_count > 0 {
130+
println!("✅ Fixed {} violation(s)", fixed_count);
131+
} else {
132+
println!("ℹ️ No auto-fixable violations found or analysis doesn't support auto-fix");
133+
}
134+
135+
Ok(())
136+
}
137+
138+
#[cfg(test)]
139+
mod tests {
140+
#[test]
141+
fn test_module_exists() {
142+
// Smoke test to ensure module compiles
143+
}
144+
}

0 commit comments

Comments
 (0)