1use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6use ryo_analysis::{
7 AnalysisContext, SpecFlowBuilderV2, SpecFlowGraphV2, SpecSource, SymbolId, SymbolKind,
8 SymbolRegistry, TypeAliasRegistryBuilder,
9};
10use thiserror::Error;
11
12use super::response::{
13 LintSeverity, SpecGroupInfo, SpecInfo, SpecLintIssue, SpecLintResult, SpecRelation,
14 SpecRelationKind, SpecShowResponse, SpecSourceKind, SpecStats,
15};
16use crate::Project;
17
18#[derive(Debug, Error)]
20pub enum SpecError {
21 #[error("Project error: {0}")]
23 Project(String),
24}
25
26pub struct SpecService;
28
29impl SpecService {
30 pub fn new() -> Self {
32 Self
33 }
34
35 pub fn from_context(ctx: &AnalysisContext) -> Result<SpecFlowData, SpecError> {
40 let symbol_lookup = build_symbol_lookup(ctx.registry());
42
43 let alias_files: Vec<_> = ctx
45 .files()
46 .iter()
47 .map(|(wfp, pure_file)| (wfp.clone(), pure_file.as_ref()))
48 .collect();
49
50 let alias_registry_builder = TypeAliasRegistryBuilder::new(ctx.registry(), &symbol_lookup);
51 let alias_registry = alias_registry_builder.build(&alias_files);
52
53 let specflow_builder = SpecFlowBuilderV2::new(&alias_registry, ctx.registry());
55 let specflow = specflow_builder.build();
56
57 Ok(SpecFlowData { specflow })
58 }
59
60 fn build_context(project: &Project) -> Result<AnalysisContext, SpecError> {
62 AnalysisContext::from_workspace_root(project.workspace_root())
63 .map_err(|e| SpecError::Project(e.to_string()))
64 }
65
66 pub fn load(&self, project: &Project) -> Result<SpecFlowData, SpecError> {
71 let ctx = Self::build_context(project)?;
72 Self::from_context(&ctx)
73 }
74
75 pub fn from_path(&self, path: &Path) -> Result<SpecFlowData, SpecError> {
77 let project = Project::load(path).map_err(|e| SpecError::Project(e.to_string()))?;
78 self.load(&project)
79 }
80
81 pub fn show(&self, project: &Project) -> Result<SpecShowResponse, SpecError> {
83 let data = self.load(project)?;
84 Ok(data.to_show_response())
85 }
86
87 pub fn stats(&self, project: &Project) -> Result<SpecStats, SpecError> {
89 let data = self.load(project)?;
90 Ok(data.stats())
91 }
92
93 pub fn groups(&self, project: &Project) -> Result<Vec<String>, SpecError> {
95 let data = self.load(project)?;
96 Ok(data.group_names())
97 }
98
99 pub fn specs_in_group(
101 &self,
102 project: &Project,
103 group: &str,
104 ) -> Result<Vec<SpecInfo>, SpecError> {
105 let data = self.load(project)?;
106 Ok(data.specs_in_group(group))
107 }
108
109 pub fn lint(&self, project: &Project) -> Result<SpecLintResult, SpecError> {
111 let data = self.load(project)?;
112 Ok(data.lint())
113 }
114
115 pub fn mermaid(&self, project: &Project) -> Result<String, SpecError> {
117 let data = self.load(project)?;
118 Ok(data.to_mermaid())
119 }
120}
121
122impl Default for SpecService {
123 fn default() -> Self {
124 Self::new()
125 }
126}
127
128pub struct SpecFlowData {
130 pub specflow: SpecFlowGraphV2,
131}
132
133impl SpecFlowData {
134 pub fn stats(&self) -> SpecStats {
136 SpecStats {
137 groups: self.specflow.group_count(),
138 specs: self.specflow.spec_count(),
139 nodes: self.specflow.node_count(),
140 edges: self.specflow.edge_count(),
141 }
142 }
143
144 pub fn group_names(&self) -> Vec<String> {
146 self.specflow.group_names().map(|s| s.to_string()).collect()
147 }
148
149 pub fn specs_in_group(&self, group: &str) -> Vec<SpecInfo> {
151 self.specflow
152 .specs_in_group_by_name(group)
153 .filter_map(|spec_id| {
154 self.specflow.get_spec_alias(spec_id).map(|data| {
155 let alias_name = self
156 .specflow
157 .spec_name(spec_id)
158 .unwrap_or("<unknown>")
159 .to_string();
160 let wrapped_type_name = self
161 .specflow
162 .wrapped_type_name(spec_id)
163 .unwrap_or("<unknown>")
164 .to_string();
165
166 SpecInfo {
167 alias_name,
168 wrapped_type_name,
169 source: convert_source(data.source),
170 }
171 })
172 })
173 .collect()
174 }
175
176 pub fn to_show_response(&self) -> SpecShowResponse {
178 let groups: Vec<SpecGroupInfo> = self
179 .specflow
180 .group_names()
181 .map(|name| SpecGroupInfo {
182 name: name.to_string(),
183 specs: self.specs_in_group(name),
184 })
185 .collect();
186
187 let relations = self.collect_relations();
188 let stats = self.stats();
189
190 SpecShowResponse {
191 groups,
192 relations,
193 stats,
194 }
195 }
196
197 fn collect_relations(&self) -> Vec<SpecRelation> {
199 let mut relations = Vec::new();
200
201 for group_name in self.specflow.group_names() {
202 for spec_id in self.specflow.specs_in_group_by_name(group_name) {
203 if let Some(from_name) = self.specflow.spec_name(spec_id) {
204 for dep_id in self.specflow.dependencies(spec_id) {
205 if let Some(to_name) = self.specflow.spec_name(dep_id) {
206 relations.push(SpecRelation {
207 from: from_name.to_string(),
208 to: to_name.to_string(),
209 kind: SpecRelationKind::DependsOn,
210 });
211 }
212 }
213 }
214 }
215 }
216
217 relations
218 }
219
220 pub fn lint(&self) -> SpecLintResult {
222 let mut issues = Vec::new();
223
224 if self.specflow.is_empty() {
226 issues.push(SpecLintIssue {
227 severity: LintSeverity::Warning,
228 message: "No spec markers found in project".to_string(),
229 location: None,
230 });
231 }
232
233 let mut seen_specs: HashSet<String> = HashSet::new();
235 for group_name in self.specflow.group_names() {
236 for spec_id in self.specflow.specs_in_group_by_name(group_name) {
237 if let Some(alias_name) = self.specflow.spec_name(spec_id) {
238 if seen_specs.contains(alias_name) {
239 issues.push(SpecLintIssue {
240 severity: LintSeverity::Warning,
241 message: format!(
242 "Spec '{}' appears in multiple groups (including '{}')",
243 alias_name, group_name
244 ),
245 location: None,
246 });
247 }
248 seen_specs.insert(alias_name.to_string());
249 }
250 }
251 }
252
253 for group_name in self.specflow.group_names() {
255 for spec_id in self.specflow.specs_in_group_by_name(group_name) {
256 if let Some(alias_name) = self.specflow.spec_name(spec_id) {
257 for dep_id in self.specflow.dependencies(spec_id) {
258 if spec_id == dep_id {
260 issues.push(SpecLintIssue {
261 severity: LintSeverity::Error,
262 message: format!(
263 "Self-reference detected: '{}' depends on itself",
264 alias_name
265 ),
266 location: None,
267 });
268 }
269
270 for back_dep_id in self.specflow.dependencies(dep_id) {
272 if back_dep_id == spec_id {
273 if let Some(dep_name) = self.specflow.spec_name(dep_id) {
274 issues.push(SpecLintIssue {
275 severity: LintSeverity::Warning,
276 message: format!(
277 "Circular dependency: '{}' <-> '{}'",
278 alias_name, dep_name
279 ),
280 location: None,
281 });
282 }
283 }
284 }
285 }
286 }
287 }
288 }
289
290 issues.dedup_by(|a, b| a.message == b.message);
292
293 let warnings = issues
294 .iter()
295 .filter(|i| i.severity == LintSeverity::Warning)
296 .count();
297 let errors = issues
298 .iter()
299 .filter(|i| i.severity == LintSeverity::Error)
300 .count();
301
302 SpecLintResult {
303 issues,
304 warnings,
305 errors,
306 }
307 }
308
309 pub fn to_mermaid(&self) -> String {
311 let mut lines = vec!["graph TD".to_string()];
312
313 for group_name in self.specflow.group_names() {
315 lines.push(format!(" subgraph {}", group_name));
316 for spec_id in self.specflow.specs_in_group_by_name(group_name) {
317 if let Some(alias_name) = self.specflow.spec_name(spec_id) {
318 lines.push(format!(" {}[{}]", alias_name, alias_name));
319 }
320 }
321 lines.push(" end".to_string());
322 }
323
324 for group_name in self.specflow.group_names() {
326 for spec_id in self.specflow.specs_in_group_by_name(group_name) {
327 if let Some(alias_name) = self.specflow.spec_name(spec_id) {
328 for dep_id in self.specflow.dependencies(spec_id) {
329 if let Some(dep_name) = self.specflow.spec_name(dep_id) {
330 lines.push(format!(" {}-->|depends|{}", alias_name, dep_name));
331 }
332 }
333 }
334 }
335 }
336
337 lines.join("\n")
338 }
339}
340
341fn build_symbol_lookup(registry: &SymbolRegistry) -> HashMap<String, SymbolId> {
343 let mut lookup = HashMap::new();
344
345 for (id, path) in registry.iter() {
346 if let Some(
347 SymbolKind::Struct | SymbolKind::Enum | SymbolKind::Trait | SymbolKind::TypeAlias,
348 ) = registry.kind(id)
349 {
350 lookup.insert(path.to_string(), id);
352 if let Some(name) = path.segments().last() {
354 lookup.insert(name.to_string(), id);
355 }
356 }
357 }
358
359 lookup
360}
361
362fn convert_source(source: SpecSource) -> SpecSourceKind {
364 match source {
365 SpecSource::TypeAlias => SpecSourceKind::TypeAlias,
366 SpecSource::Comment => SpecSourceKind::Comment,
367 SpecSource::Inferred => SpecSourceKind::Inferred,
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 fn create_test_context(source: &str) -> AnalysisContext {
376 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
377 let src_dir = temp_dir.path().join("src");
378 std::fs::create_dir_all(&src_dir).expect("Failed to create src dir");
379
380 let lib_rs = src_dir.join("lib.rs");
381 std::fs::write(&lib_rs, source).expect("Failed to write lib.rs");
382
383 let cargo_toml = temp_dir.path().join("Cargo.toml");
384 std::fs::write(
385 &cargo_toml,
386 r#"[package]
387name = "test_crate"
388version = "0.1.0"
389edition = "2021"
390"#,
391 )
392 .expect("Failed to write Cargo.toml");
393
394 let workspace_root = temp_dir.path().to_path_buf();
396 std::mem::forget(temp_dir);
397
398 AnalysisContext::from_workspace_root(&workspace_root)
399 .expect("Failed to create AnalysisContext")
400 }
401
402 #[test]
403 fn test_from_context_empty() {
404 let ctx = create_test_context("");
405 let result = SpecService::from_context(&ctx);
406 assert!(result.is_ok());
407 let data = result.unwrap();
408 assert_eq!(data.stats().specs, 0);
409 }
410
411 #[test]
412 fn test_from_context_with_type_alias() {
413 let ctx = create_test_context(
414 r#"
415 pub type UserId = String;
416 pub type Email = String;
417 "#,
418 );
419 let result = SpecService::from_context(&ctx);
420 assert!(result.is_ok());
421 let _data = result.unwrap();
423 }
424
425 #[test]
426 fn test_from_context_with_struct() {
427 let ctx = create_test_context(
428 r#"
429 pub struct User {
430 pub id: String,
431 pub name: String,
432 }
433 "#,
434 );
435 let result = SpecService::from_context(&ctx);
436 assert!(result.is_ok());
437 }
438
439 #[test]
440 fn test_from_context_groups() {
441 let ctx = create_test_context(
442 r#"
443 pub type UserId = String;
444 "#,
445 );
446 let result = SpecService::from_context(&ctx);
447 assert!(result.is_ok());
448 let data = result.unwrap();
449 let _groups = data.group_names();
451 }
452
453 #[test]
454 fn test_from_context_lint() {
455 let ctx = create_test_context("");
456 let result = SpecService::from_context(&ctx);
457 assert!(result.is_ok());
458 let data = result.unwrap();
459 let lint = data.lint();
460 assert_eq!(lint.errors, 0);
462 }
463
464 #[test]
465 fn test_from_context_mermaid() {
466 let ctx = create_test_context(
467 r#"
468 pub type UserId = String;
469 "#,
470 );
471 let result = SpecService::from_context(&ctx);
472 assert!(result.is_ok());
473 let data = result.unwrap();
474 let mermaid = data.to_mermaid();
475 assert!(mermaid.starts_with("graph TD"));
476 }
477
478 #[test]
479 fn test_from_context_complex_source() {
480 let source = r#"
482 pub type UserId = String;
483 pub struct User { pub id: UserId }
484 "#;
485 let ctx = create_test_context(source);
486
487 let result = SpecService::from_context(&ctx);
488 assert!(result.is_ok());
489 let data = result.unwrap();
490 let _stats = data.stats();
492 }
493}