use std::collections::{HashMap, HashSet};
use std::path::Path;
use ryo_analysis::{
AnalysisContext, SpecFlowBuilderV2, SpecFlowGraphV2, SpecSource, SymbolId, SymbolKind,
SymbolRegistry, TypeAliasRegistryBuilder,
};
use thiserror::Error;
use super::response::{
LintSeverity, SpecGroupInfo, SpecInfo, SpecLintIssue, SpecLintResult, SpecRelation,
SpecRelationKind, SpecShowResponse, SpecSourceKind, SpecStats,
};
use crate::Project;
#[derive(Debug, Error)]
pub enum SpecError {
#[error("Project error: {0}")]
Project(String),
}
pub struct SpecService;
impl SpecService {
pub fn new() -> Self {
Self
}
pub fn from_context(ctx: &AnalysisContext) -> Result<SpecFlowData, SpecError> {
let symbol_lookup = build_symbol_lookup(ctx.registry());
let alias_files: Vec<_> = ctx
.files()
.iter()
.map(|(wfp, pure_file)| (wfp.clone(), pure_file.as_ref()))
.collect();
let alias_registry_builder = TypeAliasRegistryBuilder::new(ctx.registry(), &symbol_lookup);
let alias_registry = alias_registry_builder.build(&alias_files);
let specflow_builder = SpecFlowBuilderV2::new(&alias_registry, ctx.registry());
let specflow = specflow_builder.build();
Ok(SpecFlowData { specflow })
}
fn build_context(project: &Project) -> Result<AnalysisContext, SpecError> {
AnalysisContext::from_workspace_root(project.workspace_root())
.map_err(|e| SpecError::Project(e.to_string()))
}
pub fn load(&self, project: &Project) -> Result<SpecFlowData, SpecError> {
let ctx = Self::build_context(project)?;
Self::from_context(&ctx)
}
pub fn from_path(&self, path: &Path) -> Result<SpecFlowData, SpecError> {
let project = Project::load(path).map_err(|e| SpecError::Project(e.to_string()))?;
self.load(&project)
}
pub fn show(&self, project: &Project) -> Result<SpecShowResponse, SpecError> {
let data = self.load(project)?;
Ok(data.to_show_response())
}
pub fn stats(&self, project: &Project) -> Result<SpecStats, SpecError> {
let data = self.load(project)?;
Ok(data.stats())
}
pub fn groups(&self, project: &Project) -> Result<Vec<String>, SpecError> {
let data = self.load(project)?;
Ok(data.group_names())
}
pub fn specs_in_group(
&self,
project: &Project,
group: &str,
) -> Result<Vec<SpecInfo>, SpecError> {
let data = self.load(project)?;
Ok(data.specs_in_group(group))
}
pub fn lint(&self, project: &Project) -> Result<SpecLintResult, SpecError> {
let data = self.load(project)?;
Ok(data.lint())
}
pub fn mermaid(&self, project: &Project) -> Result<String, SpecError> {
let data = self.load(project)?;
Ok(data.to_mermaid())
}
}
impl Default for SpecService {
fn default() -> Self {
Self::new()
}
}
pub struct SpecFlowData {
pub specflow: SpecFlowGraphV2,
}
impl SpecFlowData {
pub fn stats(&self) -> SpecStats {
SpecStats {
groups: self.specflow.group_count(),
specs: self.specflow.spec_count(),
nodes: self.specflow.node_count(),
edges: self.specflow.edge_count(),
}
}
pub fn group_names(&self) -> Vec<String> {
self.specflow.group_names().map(|s| s.to_string()).collect()
}
pub fn specs_in_group(&self, group: &str) -> Vec<SpecInfo> {
self.specflow
.specs_in_group_by_name(group)
.filter_map(|spec_id| {
self.specflow.get_spec_alias(spec_id).map(|data| {
let alias_name = self
.specflow
.spec_name(spec_id)
.unwrap_or("<unknown>")
.to_string();
let wrapped_type_name = self
.specflow
.wrapped_type_name(spec_id)
.unwrap_or("<unknown>")
.to_string();
SpecInfo {
alias_name,
wrapped_type_name,
source: convert_source(data.source),
}
})
})
.collect()
}
pub fn to_show_response(&self) -> SpecShowResponse {
let groups: Vec<SpecGroupInfo> = self
.specflow
.group_names()
.map(|name| SpecGroupInfo {
name: name.to_string(),
specs: self.specs_in_group(name),
})
.collect();
let relations = self.collect_relations();
let stats = self.stats();
SpecShowResponse {
groups,
relations,
stats,
}
}
fn collect_relations(&self) -> Vec<SpecRelation> {
let mut relations = Vec::new();
for group_name in self.specflow.group_names() {
for spec_id in self.specflow.specs_in_group_by_name(group_name) {
if let Some(from_name) = self.specflow.spec_name(spec_id) {
for dep_id in self.specflow.dependencies(spec_id) {
if let Some(to_name) = self.specflow.spec_name(dep_id) {
relations.push(SpecRelation {
from: from_name.to_string(),
to: to_name.to_string(),
kind: SpecRelationKind::DependsOn,
});
}
}
}
}
}
relations
}
pub fn lint(&self) -> SpecLintResult {
let mut issues = Vec::new();
if self.specflow.is_empty() {
issues.push(SpecLintIssue {
severity: LintSeverity::Warning,
message: "No spec markers found in project".to_string(),
location: None,
});
}
let mut seen_specs: HashSet<String> = HashSet::new();
for group_name in self.specflow.group_names() {
for spec_id in self.specflow.specs_in_group_by_name(group_name) {
if let Some(alias_name) = self.specflow.spec_name(spec_id) {
if seen_specs.contains(alias_name) {
issues.push(SpecLintIssue {
severity: LintSeverity::Warning,
message: format!(
"Spec '{}' appears in multiple groups (including '{}')",
alias_name, group_name
),
location: None,
});
}
seen_specs.insert(alias_name.to_string());
}
}
}
for group_name in self.specflow.group_names() {
for spec_id in self.specflow.specs_in_group_by_name(group_name) {
if let Some(alias_name) = self.specflow.spec_name(spec_id) {
for dep_id in self.specflow.dependencies(spec_id) {
if spec_id == dep_id {
issues.push(SpecLintIssue {
severity: LintSeverity::Error,
message: format!(
"Self-reference detected: '{}' depends on itself",
alias_name
),
location: None,
});
}
for back_dep_id in self.specflow.dependencies(dep_id) {
if back_dep_id == spec_id {
if let Some(dep_name) = self.specflow.spec_name(dep_id) {
issues.push(SpecLintIssue {
severity: LintSeverity::Warning,
message: format!(
"Circular dependency: '{}' <-> '{}'",
alias_name, dep_name
),
location: None,
});
}
}
}
}
}
}
}
issues.dedup_by(|a, b| a.message == b.message);
let warnings = issues
.iter()
.filter(|i| i.severity == LintSeverity::Warning)
.count();
let errors = issues
.iter()
.filter(|i| i.severity == LintSeverity::Error)
.count();
SpecLintResult {
issues,
warnings,
errors,
}
}
pub fn to_mermaid(&self) -> String {
let mut lines = vec!["graph TD".to_string()];
for group_name in self.specflow.group_names() {
lines.push(format!(" subgraph {}", group_name));
for spec_id in self.specflow.specs_in_group_by_name(group_name) {
if let Some(alias_name) = self.specflow.spec_name(spec_id) {
lines.push(format!(" {}[{}]", alias_name, alias_name));
}
}
lines.push(" end".to_string());
}
for group_name in self.specflow.group_names() {
for spec_id in self.specflow.specs_in_group_by_name(group_name) {
if let Some(alias_name) = self.specflow.spec_name(spec_id) {
for dep_id in self.specflow.dependencies(spec_id) {
if let Some(dep_name) = self.specflow.spec_name(dep_id) {
lines.push(format!(" {}-->|depends|{}", alias_name, dep_name));
}
}
}
}
}
lines.join("\n")
}
}
fn build_symbol_lookup(registry: &SymbolRegistry) -> HashMap<String, SymbolId> {
let mut lookup = HashMap::new();
for (id, path) in registry.iter() {
if let Some(
SymbolKind::Struct | SymbolKind::Enum | SymbolKind::Trait | SymbolKind::TypeAlias,
) = registry.kind(id)
{
lookup.insert(path.to_string(), id);
if let Some(name) = path.segments().last() {
lookup.insert(name.to_string(), id);
}
}
}
lookup
}
fn convert_source(source: SpecSource) -> SpecSourceKind {
match source {
SpecSource::TypeAlias => SpecSourceKind::TypeAlias,
SpecSource::Comment => SpecSourceKind::Comment,
SpecSource::Inferred => SpecSourceKind::Inferred,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_context(source: &str) -> AnalysisContext {
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let src_dir = temp_dir.path().join("src");
std::fs::create_dir_all(&src_dir).expect("Failed to create src dir");
let lib_rs = src_dir.join("lib.rs");
std::fs::write(&lib_rs, source).expect("Failed to write lib.rs");
let cargo_toml = temp_dir.path().join("Cargo.toml");
std::fs::write(
&cargo_toml,
r#"[package]
name = "test_crate"
version = "0.1.0"
edition = "2021"
"#,
)
.expect("Failed to write Cargo.toml");
let workspace_root = temp_dir.path().to_path_buf();
std::mem::forget(temp_dir);
AnalysisContext::from_workspace_root(&workspace_root)
.expect("Failed to create AnalysisContext")
}
#[test]
fn test_from_context_empty() {
let ctx = create_test_context("");
let result = SpecService::from_context(&ctx);
assert!(result.is_ok());
let data = result.unwrap();
assert_eq!(data.stats().specs, 0);
}
#[test]
fn test_from_context_with_type_alias() {
let ctx = create_test_context(
r#"
pub type UserId = String;
pub type Email = String;
"#,
);
let result = SpecService::from_context(&ctx);
assert!(result.is_ok());
let _data = result.unwrap();
}
#[test]
fn test_from_context_with_struct() {
let ctx = create_test_context(
r#"
pub struct User {
pub id: String,
pub name: String,
}
"#,
);
let result = SpecService::from_context(&ctx);
assert!(result.is_ok());
}
#[test]
fn test_from_context_groups() {
let ctx = create_test_context(
r#"
pub type UserId = String;
"#,
);
let result = SpecService::from_context(&ctx);
assert!(result.is_ok());
let data = result.unwrap();
let _groups = data.group_names();
}
#[test]
fn test_from_context_lint() {
let ctx = create_test_context("");
let result = SpecService::from_context(&ctx);
assert!(result.is_ok());
let data = result.unwrap();
let lint = data.lint();
assert_eq!(lint.errors, 0);
}
#[test]
fn test_from_context_mermaid() {
let ctx = create_test_context(
r#"
pub type UserId = String;
"#,
);
let result = SpecService::from_context(&ctx);
assert!(result.is_ok());
let data = result.unwrap();
let mermaid = data.to_mermaid();
assert!(mermaid.starts_with("graph TD"));
}
#[test]
fn test_from_context_complex_source() {
let source = r#"
pub type UserId = String;
pub struct User { pub id: UserId }
"#;
let ctx = create_test_context(source);
let result = SpecService::from_context(&ctx);
assert!(result.is_ok());
let data = result.unwrap();
let _stats = data.stats();
}
}