Skip to main content

ryo_app/spec/
service.rs

1//! SpecFlow service - builds and queries SpecFlowGraphV2.
2
3use 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/// SpecFlow service error.
19#[derive(Debug, Error)]
20pub enum SpecError {
21    /// Project error.
22    #[error("Project error: {0}")]
23    Project(String),
24}
25
26/// SpecFlow service for building and querying SpecFlowGraphV2.
27pub struct SpecService;
28
29impl SpecService {
30    /// Create a new SpecService.
31    pub fn new() -> Self {
32        Self
33    }
34
35    /// Build SpecFlowData from an existing AnalysisContext.
36    ///
37    /// This is the preferred method for server mode where AnalysisContext
38    /// is already loaded and cached.
39    pub fn from_context(ctx: &AnalysisContext) -> Result<SpecFlowData, SpecError> {
40        // Build symbol lookup
41        let symbol_lookup = build_symbol_lookup(ctx.registry());
42
43        // Build TypeAliasRegistry
44        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        // Build SpecFlowGraphV2 (DoD - names pre-resolved)
54        let specflow_builder = SpecFlowBuilderV2::new(&alias_registry, ctx.registry());
55        let specflow = specflow_builder.build();
56
57        Ok(SpecFlowData { specflow })
58    }
59
60    /// Create AnalysisContext from a project using workspace_root.
61    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    /// Load SpecFlowGraphV2 from a project.
67    ///
68    /// **Note**: This rebuilds AnalysisContext from scratch. For server mode,
69    /// prefer `from_context()` which reuses the existing context.
70    pub fn load(&self, project: &Project) -> Result<SpecFlowData, SpecError> {
71        let ctx = Self::build_context(project)?;
72        Self::from_context(&ctx)
73    }
74
75    /// Load SpecFlowGraphV2 from a path.
76    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    /// Get spec show response (groups, relations, stats).
82    pub fn show(&self, project: &Project) -> Result<SpecShowResponse, SpecError> {
83        let data = self.load(project)?;
84        Ok(data.to_show_response())
85    }
86
87    /// Get stats only.
88    pub fn stats(&self, project: &Project) -> Result<SpecStats, SpecError> {
89        let data = self.load(project)?;
90        Ok(data.stats())
91    }
92
93    /// Get groups only.
94    pub fn groups(&self, project: &Project) -> Result<Vec<String>, SpecError> {
95        let data = self.load(project)?;
96        Ok(data.group_names())
97    }
98
99    /// Get specs in a group.
100    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    /// Lint specs for consistency issues.
110    pub fn lint(&self, project: &Project) -> Result<SpecLintResult, SpecError> {
111        let data = self.load(project)?;
112        Ok(data.lint())
113    }
114
115    /// Generate Mermaid diagram.
116    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
128/// Loaded SpecFlow data (DoD - no registry needed).
129pub struct SpecFlowData {
130    pub specflow: SpecFlowGraphV2,
131}
132
133impl SpecFlowData {
134    /// Get statistics.
135    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    /// Get all group names.
145    pub fn group_names(&self) -> Vec<String> {
146        self.specflow.group_names().map(|s| s.to_string()).collect()
147    }
148
149    /// Get specs in a group.
150    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    /// Convert to SpecShowResponse.
177    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    /// Collect all dependency relations.
198    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    /// Lint for consistency issues.
221    pub fn lint(&self) -> SpecLintResult {
222        let mut issues = Vec::new();
223
224        // Check for empty specs
225        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        // Check for duplicate names across groups
234        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        // Check for self-references and circular dependencies
254        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                        // Self-reference
259                        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                        // 2-hop circular
271                        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        // Deduplicate
291        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    /// Generate Mermaid diagram.
310    pub fn to_mermaid(&self) -> String {
311        let mut lines = vec!["graph TD".to_string()];
312
313        // Subgraphs for groups
314        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        // Relations
325        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
341/// Build symbol lookup map from SymbolRegistry.
342fn 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            // Full path
351            lookup.insert(path.to_string(), id);
352            // Short name (last segment)
353            if let Some(name) = path.segments().last() {
354                lookup.insert(name.to_string(), id);
355            }
356        }
357    }
358
359    lookup
360}
361
362/// Convert SpecSource to SpecSourceKind.
363fn 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        // Keep temp_dir alive by leaking it (tests don't need cleanup)
395        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        // Type aliases are parsed successfully
422        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        // group_names() should return without panic
450        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        // Empty context should have no lint issues
461        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        // Verify from_context handles complex source
481        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        // Should have parsed stats
491        let _stats = data.stats();
492    }
493}