1mod types;
50
51pub use types::*;
52
53use crate::config::{RyoConfig, SuggestConfig};
54use crate::intent::{ConflictStrategy, Goal, Intent};
55use crate::planner::{PlanError, Planner};
56use crate::project::Project;
57use crate::storage::Storage;
58use ryo_analysis::{
59 AnalysisContext, DiscoveryEngine, DiscoveryQuery, SymbolId, SymbolKind, UuidPersistence,
60};
61use ryo_executor::{collect_affected_ids, BlueprintExecutor, Conflict, ParallelBlueprint};
62use ryo_query_language::{MatchResult, MatchView, QueryExecutor};
63use ryo_storage::{FileUuidStorage, TxAction, TxLog};
64use ryo_suggest::{
65 EnhancedSuggestion, RuleStore, SafetyLevel, StringErrorType, SuggestId, SuggestQuery,
66 SuggestRegistry, SuggestService, UnnecessaryClone, UnwrapToExpect,
67};
68use ryo_symbol::{ResolveError, WorkspaceType};
69use ryo_verification::{
70 CargoVerifier, FileChange, GraphVerifier, InMemoryVerifier, VerificationInput,
71 VerificationPipeline,
72};
73use std::cell::RefCell;
74use std::path::{Path, PathBuf};
75use std::time::Instant;
76
77type DefinitionDetails = (
78 Vec<types::TypeFieldInfo>,
79 Vec<types::TypeVariantInfo>,
80 Vec<types::TypeParamInfo>,
81 Option<String>,
82 Vec<String>,
83 Option<String>,
84 Vec<String>,
85);
86
87fn convert_match_result_to_discovered_symbol(m: MatchResult) -> types::DiscoveredSymbol {
89 let (view_mode, definition, doc, body, snippet) = match m.view {
90 MatchView::Snippet { text } => ("snippet".to_string(), None, None, None, Some(text)),
91 MatchView::Precise => ("precise".to_string(), None, None, None, None),
92 MatchView::Def {
93 module_path: _,
94 definition,
95 doc,
96 } => ("def".to_string(), Some(definition), doc, None, None),
97 MatchView::Full {
98 module_path: _,
99 definition,
100 doc,
101 body,
102 } => ("full".to_string(), Some(definition), doc, Some(body), None),
103 };
104
105 types::DiscoveredSymbol {
106 id: m.id,
107 uuid: m.uuid,
108 path: m.path,
109 name: m.name,
110 kind: m.node_kind,
111 view_mode,
112 definition,
113 doc,
114 body,
115 snippet,
116 }
117}
118
119fn format_generics(g: &ryo_analysis::detail_store::GenericInfo) -> String {
124 let mut parts = Vec::new();
125 for lt in &g.lifetimes {
126 parts.push(lt.clone());
127 }
128 for tp in &g.type_params {
129 parts.push(tp.clone());
130 }
131 for (name, ty) in &g.const_params {
132 parts.push(format!("const {}: {}", name, ty));
133 }
134 format!("<{}>", parts.join(", "))
135}
136
137fn extract_parent_path_string(intent: &Intent) -> Option<String> {
138 match intent {
139 Intent::AddCode { symbol_path, .. } => {
140 symbol_path.as_ref().filter(|p| p.contains("::")).cloned()
142 }
143 _ => None,
145 }
146}
147
148#[derive(Debug, thiserror::Error)]
150pub enum ApiError {
151 #[error("Project error: {0}")]
152 Project(#[from] crate::project::ProjectError),
153
154 #[error("Planning error: {0}")]
155 Planning(#[from] PlanError),
156
157 #[error("Execution error: {0}")]
158 Execution(String),
159
160 #[error("Storage error: {0}")]
161 Storage(#[from] ryo_storage::StorageError),
162
163 #[error("Invalid goal: {0}")]
164 InvalidGoal(String),
165
166 #[error("Conflicts detected: {0} conflicts require resolution")]
167 Conflicts(usize),
168
169 #[error("Syntax error after mutation: {0}")]
170 SyntaxError(String),
171
172 #[error("Not found: {0}")]
173 NotFound(String),
174
175 #[error("Graph error: {0}")]
176 Graph(#[from] crate::graph::GraphError),
177
178 #[error("Discover error: {0}")]
179 Discover(#[from] crate::discover::DiscoverError),
180
181 #[error("Spec error: {0}")]
182 Spec(#[from] crate::spec::SpecError),
183
184 #[error("{0}")]
185 Resolve(#[from] ResolveError),
186
187 #[error("File sync error: {0}")]
188 Sync(#[from] ryo_executor::SyncError),
189}
190
191pub type ApiResult<T> = Result<T, ApiError>;
193
194#[derive(Debug, Clone, Default)]
196pub struct ExecuteOptions {
197 pub dry_run: bool,
200
201 pub check_syntax: bool,
204}
205
206impl ExecuteOptions {
207 pub fn new() -> Self {
209 Self::default()
210 }
211
212 pub fn dry_run(mut self) -> Self {
214 self.dry_run = true;
215 self
216 }
217
218 pub fn check_syntax(mut self) -> Self {
220 self.check_syntax = true;
221 self
222 }
223}
224
225#[derive(Debug, Clone)]
227pub struct ExecutionResult {
228 pub status: ExecutionStatus,
231 pub files_modified: usize,
233 pub total_changes: usize,
235 pub session_id: Option<String>,
237 pub log: TxLog,
239 pub modified_files: Vec<PathBuf>,
241 pub conflicts: Vec<Conflict>,
243 pub syntax_errors: Vec<String>,
245 pub success: bool,
247}
248
249impl ExecutionResult {
250 pub fn with_status(status: ExecutionStatus) -> Self {
252 let success = status.is_success();
253 Self {
254 status,
255 files_modified: 0,
256 total_changes: 0,
257 session_id: None,
258 log: TxLog::new(),
259 modified_files: vec![],
260 conflicts: vec![],
261 syntax_errors: vec![],
262 success,
263 }
264 }
265
266 pub fn ok(changes: usize, files: usize) -> Self {
268 let reason = format!("{} changes in {} files", changes, files);
269 Self {
270 status: ExecutionStatus::ok(reason),
271 files_modified: files,
272 total_changes: changes,
273 session_id: None,
274 log: TxLog::new(),
275 modified_files: vec![],
276 conflicts: vec![],
277 syntax_errors: vec![],
278 success: true,
279 }
280 }
281
282 pub fn no_change(reason: impl Into<String>) -> Self {
284 Self {
285 status: ExecutionStatus::no_change(reason),
286 files_modified: 0,
287 total_changes: 0,
288 session_id: None,
289 log: TxLog::new(),
290 modified_files: vec![],
291 conflicts: vec![],
292 syntax_errors: vec![],
293 success: true,
294 }
295 }
296}
297
298pub struct Api {
303 context: AnalysisContext,
307 storage: Box<dyn Storage>,
309 uuid_storage: Box<dyn UuidPersistence>,
311 project: Project,
313 spec_cache: Option<crate::spec::SpecFlowData>,
315 suggest: SuggestService,
317 suggest_config: SuggestConfig,
319 generator_store: ryo_suggest::GeneratorStore,
321}
322
323impl Api {
324 pub fn from_path(path: impl AsRef<Path>) -> ApiResult<Self> {
329 let project = Project::load(path.as_ref())?;
330 let context = AnalysisContext::from_workspace_root(project.workspace_root())
334 .map_err(|e| ApiError::Execution(format!("Context build failed: {}", e)))?;
335
336 let config = RyoConfig::load_or_default(path.as_ref());
338 let suggest_config = config.suggest.clone();
339
340 let registry = Self::create_default_registry(project.root());
342 let suggest = SuggestService::with_strategy(registry, suggest_config.to_strategy());
343
344 let generator_store = ryo_suggest::GeneratorStore::load(project.root())
346 .unwrap_or_else(|_| ryo_suggest::GeneratorStore::new());
347
348 let uuid_storage = Box::new(FileUuidStorage::new(project.root()));
350
351 Ok(Self {
352 context,
353 storage: Box::new(crate::storage::InMemoryStorage::new()),
354 uuid_storage,
355 project,
356 spec_cache: None,
357 suggest,
358 suggest_config,
359 generator_store,
360 })
361 }
362
363 pub fn with_context(
368 context: AnalysisContext,
369 project: Project,
370 storage: Box<dyn Storage>,
371 ) -> Self {
372 let suggest_config = project.config().suggest.clone();
374 let registry = Self::create_default_registry(project.root());
375 let suggest = SuggestService::with_strategy(registry, suggest_config.to_strategy());
376
377 let generator_store = ryo_suggest::GeneratorStore::load(project.root())
379 .unwrap_or_else(|_| ryo_suggest::GeneratorStore::new());
380
381 let uuid_storage = Box::new(FileUuidStorage::new(project.root()));
383
384 Self {
385 context,
386 storage,
387 uuid_storage,
388 project,
389 spec_cache: None,
390 suggest,
391 suggest_config,
392 generator_store,
393 }
394 }
395
396 pub fn new(storage: Box<dyn Storage>) -> Self {
407 let cwd = std::env::current_dir().unwrap_or_default();
408 let project = Project::load(&cwd).unwrap_or_else(|e| {
409 panic!(
410 "Api::new(): failed to load project from {:?}: {}. \
411 Use Api::from_path(path, storage) to avoid relying on the current directory.",
412 cwd, e
413 )
414 });
415 let context = AnalysisContext::from_workspace_root(project.root()).unwrap_or_else(|e| {
416 panic!(
417 "Api::new(): failed to create analysis context from {:?}: {}",
418 project.root(),
419 e
420 )
421 });
422
423 let suggest_config = project.config().suggest.clone();
425 let registry = Self::create_default_registry(project.root());
426 let suggest = SuggestService::with_strategy(registry, suggest_config.to_strategy());
427
428 let generator_store = ryo_suggest::GeneratorStore::load(project.root())
430 .unwrap_or_else(|_| ryo_suggest::GeneratorStore::new());
431
432 let uuid_storage = Box::new(FileUuidStorage::new(project.root()));
434
435 Self {
436 context,
437 storage,
438 uuid_storage,
439 project,
440 spec_cache: None,
441 suggest,
442 suggest_config,
443 generator_store,
444 }
445 }
446
447 fn create_default_registry(project_path: &Path) -> SuggestRegistry {
452 let mut registry = SuggestRegistry::new();
453
454 registry.register(UnnecessaryClone::new());
456 tracing::debug!("Registered performance suggests: UnnecessaryClone");
457
458 registry.register(UnwrapToExpect::new());
460 registry.register(StringErrorType::new());
461 tracing::debug!("Registered safety suggests: UnwrapToExpect, StringErrorType");
462
463 match RuleStore::load(project_path) {
465 Ok(store) => {
466 let count = registry.register_from_rule_store(&store);
467 tracing::debug!("Registered {} lint rules from RuleStore", count);
468 }
469 Err(e) => {
470 tracing::warn!("Failed to load RuleStore: {}", e);
471 if let Ok(store) = RuleStore::builtin_only() {
473 let count = registry.register_from_rule_store(&store);
474 tracing::debug!("Registered {} builtin lint rules (fallback)", count);
475 }
476 }
477 }
478
479 registry
480 }
481
482 fn resolve_symbol_id(
498 &self,
499 uuid: Option<&str>,
500 id: Option<&str>,
501 name: Option<&str>,
502 ) -> ApiResult<Option<SymbolId>> {
503 if let Some(uuid_str) = uuid {
505 let uuid = uuid::Uuid::parse_str(uuid_str).map_err(|_| {
506 ApiError::InvalidGoal(format!("Invalid UUID format: '{}'", uuid_str))
507 })?;
508 return self
509 .context
510 .registry
511 .lookup_by_uuid(uuid)
512 .map(Some)
513 .ok_or_else(|| {
514 ApiError::NotFound(format!("UUID '{}' not found in registry", uuid_str))
515 });
516 }
517
518 if let Some(id_str) = id {
520 let symbol_id = SymbolId::parse(id_str).ok_or_else(|| {
521 ApiError::InvalidGoal(format!(
522 "Invalid SymbolId format: '{}'. Expected format: '165v1'",
523 id_str
524 ))
525 })?;
526 if self.context.registry.resolve(symbol_id).is_none() {
528 let info = self
529 .context
530 .registry
531 .resolve(symbol_id)
532 .map(|m| format!(" (symbol: {})", m.name()))
533 .unwrap_or_default();
534 return Err(ApiError::NotFound(format!("'{}'{}", id_str, info)));
535 }
536 return Ok(Some(symbol_id));
537 }
538
539 if let Some(_name_pattern) = name {
541 return Ok(None);
544 }
545
546 Ok(None)
547 }
548
549 fn resolve_symbol_id_required(&self, uuid: Option<&str>, id: &str) -> ApiResult<SymbolId> {
554 if let Some(uuid_str) = uuid {
556 let uuid = uuid::Uuid::parse_str(uuid_str).map_err(|_| {
557 ApiError::InvalidGoal(format!("Invalid UUID format: '{}'", uuid_str))
558 })?;
559 return self.context.registry.lookup_by_uuid(uuid).ok_or_else(|| {
560 ApiError::NotFound(format!("UUID '{}' not found in registry", uuid_str))
561 });
562 }
563
564 let symbol_id = SymbolId::parse(id).ok_or_else(|| {
566 ApiError::InvalidGoal(format!(
567 "Invalid SymbolId format: '{}'. Expected format: '165v1'",
568 id
569 ))
570 })?;
571
572 if self.context.registry.resolve(symbol_id).is_none() {
574 let info = self
575 .context
576 .registry
577 .resolve(symbol_id)
578 .map(|m| format!(" (symbol: {})", m.name()))
579 .unwrap_or_default();
580 return Err(ApiError::NotFound(format!("'{}'{}", id, info)));
581 }
582
583 Ok(symbol_id)
584 }
585
586 fn no_vars_error(
591 &self,
592 name: Option<&str>,
593 display_name: &str,
594 registry: &ryo_symbol::SymbolRegistry,
595 ) -> ApiError {
596 if let Some(name) = name {
597 let type_kind = registry.find_by_name(name).into_iter().find_map(|sid| {
598 let kind = registry.kind(sid)?;
599 matches!(
600 kind,
601 ryo_symbol::SymbolKind::Struct
602 | ryo_symbol::SymbolKind::Enum
603 | ryo_symbol::SymbolKind::Trait
604 | ryo_symbol::SymbolKind::TypeAlias
605 | ryo_symbol::SymbolKind::Union
606 )
607 .then_some(kind)
608 });
609 if let Some(kind) = type_kind {
610 return ApiError::InvalidGoal(format!(
611 "'{}' is a {:?}. This command analyzes variables within functions. \
612 Pass a function/method name that uses '{}' instead.",
613 name, kind, name
614 ));
615 }
616 }
617 ApiError::NotFound(format!(
618 "'{}': no variables found in dataflow graph. Pass a function/method name.",
619 display_name
620 ))
621 }
622
623 pub fn discover(&mut self, request: DiscoverRequest) -> ApiResult<DiscoverResponse> {
640 let start = Instant::now();
641
642 if request.is_id {
644 return self.discover_by_id(&request, start);
645 }
646
647 let engine = DiscoveryEngine::new(&self.context.code_graph, &self.context.registry, None);
648
649 let sort = request.sort.map(|s| match s {
650 SortOrder::Alpha => ryo_analysis::SortOrder::Alpha,
651 SortOrder::Refs => ryo_analysis::SortOrder::Refs,
652 SortOrder::Impls => ryo_analysis::SortOrder::Impls,
653 });
654
655 let mut query = DiscoveryQuery::symbol(&request.pattern);
657
658 if let Some(kind) = request.kind {
659 query = query.kind(kind);
660 }
661 if let Some(s) = sort {
662 query = query.sort(s);
663 }
664 if request.ignore_case {
665 query = query.ignore_case();
666 }
667 if request.ignore_word_separate {
668 query = query.ignore_word_separate();
669 }
670
671 let result = engine.execute(&query);
672
673 let filtered_symbols = self.apply_discover_filters(
675 result.symbols,
676 request.is_async,
677 request.is_unsafe,
678 request.scope_path.as_deref(),
679 request.attr.as_deref(),
680 );
681
682 let (symbols_to_convert, truncated) = if let Some(limit) = request.limit {
684 if filtered_symbols.len() > limit {
685 (
686 filtered_symbols.into_iter().take(limit).collect::<Vec<_>>(),
687 true,
688 )
689 } else {
690 (filtered_symbols, result.truncated)
691 }
692 } else {
693 (filtered_symbols, result.truncated)
694 };
695
696 let total = symbols_to_convert.len();
697
698 let view_mode = request.view.unwrap_or_default();
700 let executor = QueryExecutor::new(&self.context);
701
702 let symbols: Vec<types::DiscoveredSymbol> = symbols_to_convert
704 .iter()
705 .map(|sym| {
706 let match_result = executor.to_match_result(sym, view_mode);
707 convert_match_result_to_discovered_symbol(match_result)
708 })
709 .collect();
710
711 let status = if total == 0 {
712 "not_found"
713 } else if truncated {
714 "partial"
715 } else {
716 "found"
717 };
718
719 let hint = if total == 0 && request.kind.is_some() {
721 self.generate_discover_hint(&request.pattern, &engine)
722 } else {
723 None
724 };
725
726 Ok(DiscoverResponse {
727 status: status.to_string(),
728 symbols,
729 total,
730 elapsed_ms: start.elapsed().as_millis() as u32,
731 hint,
732 })
733 }
734
735 fn discover_by_id(
740 &self,
741 request: &DiscoverRequest,
742 start: Instant,
743 ) -> ApiResult<DiscoverResponse> {
744 use ryo_analysis::DiscoveredSymbol;
745
746 if request.pattern.contains('*') || request.pattern.contains('?') {
747 return Err(ApiError::Execution(
748 "--id does not support glob patterns (* or ?)".to_string(),
749 ));
750 }
751
752 let symbol_id = SymbolId::parse(&request.pattern).ok_or_else(|| {
753 ApiError::Execution(format!(
754 "Invalid SymbolId format: '{}'. Expected format: '165v1' or 'SymbolId(165v1)'",
755 request.pattern
756 ))
757 })?;
758
759 let mut discovered = Vec::new();
760 if let Some(path) = self.context.registry.resolve(symbol_id) {
761 let kind = self
762 .context
763 .registry
764 .kind(symbol_id)
765 .unwrap_or(ryo_analysis::SymbolKind::Any);
766 let mut symbol = DiscoveredSymbol::new(symbol_id, path.clone(), kind);
767 if let Some(span) = self.context.registry.span(symbol_id) {
768 symbol = symbol.with_span(span.clone());
769 }
770 if let Some(vis) = self.context.registry.visibility(symbol_id) {
771 symbol = symbol.with_visibility(vis.clone());
772 }
773 discovered.push(symbol);
774 }
775
776 let total = discovered.len();
777 let view_mode = request.view.unwrap_or_default();
778 let executor = QueryExecutor::new(&self.context);
779
780 let symbols: Vec<types::DiscoveredSymbol> = discovered
781 .iter()
782 .map(|sym| {
783 let match_result = executor.to_match_result(sym, view_mode);
784 convert_match_result_to_discovered_symbol(match_result)
785 })
786 .collect();
787
788 let status = if total == 0 { "not_found" } else { "found" };
789
790 Ok(DiscoverResponse {
791 status: status.to_string(),
792 symbols,
793 total,
794 elapsed_ms: start.elapsed().as_millis() as u32,
795 hint: None,
796 })
797 }
798
799 pub fn overview(&self, _request: types::OverviewRequest) -> ApiResult<types::OverviewResponse> {
801 use std::collections::BTreeMap;
802
803 let start = Instant::now();
804
805 let mut crate_modules: BTreeMap<String, Vec<String>> = BTreeMap::new();
807 for symbol_id in self
808 .context
809 .registry
810 .iter_by_kind(ryo_analysis::SymbolKind::Mod)
811 {
812 let Some(path) = self.context.registry.resolve(symbol_id) else {
813 continue;
814 };
815 let path_str = path.to_string();
816 let parts: Vec<&str> = path_str.split("::").collect();
817 if let Some(crate_name) = parts.first() {
818 let module_path = parts[1..].join("::");
819 if !module_path.is_empty() {
820 crate_modules
821 .entry(crate_name.to_string())
822 .or_default()
823 .push(module_path);
824 } else {
825 crate_modules.entry(crate_name.to_string()).or_default();
826 }
827 }
828 }
829
830 let crates: Vec<types::CrateOverview> = crate_modules
831 .into_iter()
832 .map(|(name, modules)| types::CrateOverview { name, modules })
833 .collect();
834
835 let engine = DiscoveryEngine::new(&self.context.code_graph, &self.context.registry, None);
837 let struct_query = DiscoveryQuery::symbol("*")
838 .kind(ryo_analysis::SymbolKind::Struct)
839 .sort(ryo_analysis::SortOrder::Refs);
840 let struct_result = engine.execute(&struct_query);
841 let top_structs: Vec<types::OverviewSymbol> = struct_result
842 .symbols
843 .iter()
844 .take(10)
845 .map(|s| types::OverviewSymbol {
846 path: s.path.to_string(),
847 count: s.ref_count,
848 })
849 .collect();
850
851 let trait_query = DiscoveryQuery::symbol("*")
853 .kind(ryo_analysis::SymbolKind::Trait)
854 .sort(ryo_analysis::SortOrder::Impls);
855 let trait_result = engine.execute(&trait_query);
856 let top_traits: Vec<types::OverviewSymbol> = trait_result
857 .symbols
858 .iter()
859 .take(10)
860 .map(|s| types::OverviewSymbol {
861 path: s.path.to_string(),
862 count: s.impl_count,
863 })
864 .collect();
865
866 let crate_count = crates.len();
868 let kinds = [
869 (ryo_analysis::SymbolKind::Function, "Functions"),
870 (ryo_analysis::SymbolKind::Struct, "Structs"),
871 (ryo_analysis::SymbolKind::Enum, "Enums"),
872 (ryo_analysis::SymbolKind::Trait, "Traits"),
873 (ryo_analysis::SymbolKind::Impl, "Impls"),
874 (ryo_analysis::SymbolKind::Mod, "Mods"),
875 (ryo_analysis::SymbolKind::Const, "Consts"),
876 (ryo_analysis::SymbolKind::Static, "Statics"),
877 (ryo_analysis::SymbolKind::TypeAlias, "TypeAliases"),
878 ];
879 let by_kind: Vec<(String, usize)> = kinds
880 .iter()
881 .filter_map(|(kind, name)| {
882 let count = self.context.registry.iter_by_kind(*kind).count();
883 if count > 0 {
884 Some((name.to_string(), count))
885 } else {
886 None
887 }
888 })
889 .collect();
890
891 Ok(types::OverviewResponse {
892 crates,
893 top_structs,
894 top_traits,
895 stats: types::OverviewStats {
896 crate_count,
897 by_kind,
898 },
899 elapsed_ms: start.elapsed().as_millis() as u32,
900 })
901 }
902
903 pub fn query_ryoql(&self, request: types::RyoqlRequest) -> ApiResult<types::QueryResponse> {
908 use ryo_query_language::{QueryExecutor, QueryParser};
909
910 let query = QueryParser::parse(&request.query)
911 .map_err(|e| ApiError::SyntaxError(format!("RyoQL parse error: {}", e)))?;
912
913 let view_mode = query.view.or(request.default_view).unwrap_or_default();
914 let executor = QueryExecutor::new(&self.context).with_view_mode(view_mode);
915
916 executor
917 .execute(&query)
918 .map_err(|e| ApiError::Execution(format!("RyoQL execution failed: {}", e)))
919 }
920
921 #[cfg(feature = "literal-search")]
923 pub fn search_literal(
924 &self,
925 request: types::LiteralSearchRequest,
926 ) -> ApiResult<types::LiteralSearchResponse> {
927 use ryo_analysis::literal::{LiteralKind, LiteralQuery};
928
929 let start = Instant::now();
930
931 let literal_index = self
932 .context
933 .literal_index
934 .as_ref()
935 .ok_or_else(|| ApiError::Execution("Literal index not available".to_string()))?;
936
937 let mut query = LiteralQuery::new(&request.pattern).with_limit(request.limit);
938 if let Some(ref kind_str) = request.kind {
939 if let Some(kind) = LiteralKind::parse_kind(kind_str) {
940 query = query.with_kind(kind);
941 }
942 }
943
944 let matches = literal_index
945 .search(&query)
946 .map_err(|e| ApiError::Execution(format!("Literal search failed: {}", e)))?;
947
948 let total = matches.len();
949 let results: Vec<types::LiteralMatchResult> = matches
950 .into_iter()
951 .map(|m| {
952 let symbol_name = self
953 .context
954 .registry
955 .path(m.symbol_id)
956 .map(|p| p.to_string())
957 .unwrap_or_else(|| m.symbol_id.to_string());
958 types::LiteralMatchResult {
959 value: m.value,
960 kind: m.kind.to_string(),
961 symbol_id: symbol_name,
962 file_path: m.file_path,
963 score: m.score,
964 }
965 })
966 .collect();
967
968 Ok(types::LiteralSearchResponse {
969 matches: results,
970 total,
971 elapsed_ms: start.elapsed().as_millis() as u32,
972 })
973 }
974
975 #[cfg(not(feature = "literal-search"))]
977 pub fn search_literal(
978 &self,
979 _request: types::LiteralSearchRequest,
980 ) -> ApiResult<types::LiteralSearchResponse> {
981 Err(ApiError::Execution(
982 "Literal search not available. Rebuild with `literal-search` feature.".to_string(),
983 ))
984 }
985
986 fn apply_discover_filters(
988 &self,
989 symbols: Vec<ryo_analysis::DiscoveredSymbol>,
990 is_async: Option<bool>,
991 is_unsafe: Option<bool>,
992 scope_path: Option<&str>,
993 attr: Option<&str>,
994 ) -> Vec<ryo_analysis::DiscoveredSymbol> {
995 use glob::Pattern as GlobPattern;
996
997 let mut filtered = symbols;
998
999 if let Some(expected_async) = is_async {
1001 filtered.retain(|sym| {
1002 if let Some(detail) = self.context.detail_store.function(sym.id) {
1003 detail.is_async == expected_async
1004 } else {
1005 !expected_async
1007 }
1008 });
1009 }
1010
1011 if let Some(expected_unsafe) = is_unsafe {
1013 filtered.retain(|sym| {
1014 if let Some(detail) = self.context.detail_store.function(sym.id) {
1016 return detail.is_unsafe == expected_unsafe;
1017 }
1018 if let Some(detail) = self.context.detail_store.trait_(sym.id) {
1020 return detail.is_unsafe == expected_unsafe;
1021 }
1022 if let Some(detail) = self.context.detail_store.impl_(sym.id) {
1024 return detail.is_unsafe == expected_unsafe;
1025 }
1026 !expected_unsafe
1028 });
1029 }
1030
1031 if let Some(pattern) = scope_path {
1033 if let Ok(glob) = GlobPattern::new(pattern) {
1034 filtered.retain(|sym| {
1035 if let Some(ref span) = sym.span {
1036 glob.matches(span.file.as_relative().to_string_lossy().as_ref())
1037 } else {
1038 false }
1040 });
1041 }
1042 }
1043
1044 if let Some(pattern) = attr {
1046 if let Ok(glob) = GlobPattern::new(pattern) {
1047 filtered.retain(|sym| {
1048 let attrs = self.get_symbol_attrs(sym.id);
1050 attrs.iter().any(|a| glob.matches(a))
1051 });
1052 }
1053 }
1054
1055 filtered
1056 }
1057
1058 fn get_symbol_attrs(&self, id: ryo_analysis::SymbolId) -> Vec<&str> {
1060 let detail_store = &self.context.detail_store;
1061
1062 if let Some(detail) = detail_store.function(id) {
1064 return detail.attrs.iter().map(|s| s.as_str()).collect();
1065 }
1066 if let Some(detail) = detail_store.struct_(id) {
1068 return detail.attrs.iter().map(|s| s.as_str()).collect();
1069 }
1070 if let Some(detail) = detail_store.enum_(id) {
1072 return detail.attrs.iter().map(|s| s.as_str()).collect();
1073 }
1074 if let Some(detail) = detail_store.trait_(id) {
1076 return detail.attrs.iter().map(|s| s.as_str()).collect();
1077 }
1078 if let Some(detail) = detail_store.impl_(id) {
1080 return detail.attrs.iter().map(|s| s.as_str()).collect();
1081 }
1082
1083 Vec::new()
1084 }
1085
1086 fn generate_discover_hint(&self, pattern: &str, engine: &DiscoveryEngine) -> Option<String> {
1091 let glob_pattern = if pattern.contains('*') {
1093 pattern.to_string()
1094 } else {
1095 format!("*{}*", pattern)
1096 };
1097
1098 let alt_query = DiscoveryQuery::symbol(&glob_pattern)
1100 .ignore_case()
1101 .ignore_word_separate();
1102 let alt_result = engine.execute(&alt_query);
1103
1104 if alt_result.symbols.is_empty() {
1105 return None;
1106 }
1107
1108 let samples: Vec<_> = alt_result
1110 .symbols
1111 .iter()
1112 .take(5)
1113 .map(|s| format!(" - {} [{}]", s.path, s.kind))
1114 .collect();
1115
1116 let more = if alt_result.symbols.len() > 5 {
1117 format!("\n ... and {} more", alt_result.symbols.len() - 5)
1118 } else {
1119 String::new()
1120 };
1121
1122 Some(format!(
1123 "Hint: {} matches found with \"{}\" --kind any -i -w:\n{}{}",
1124 alt_result.symbols.len(),
1125 glob_pattern,
1126 samples.join("\n"),
1127 more
1128 ))
1129 }
1130
1131 pub fn suggest(&self, request: SuggestRequest) -> ApiResult<SuggestResponse> {
1146 let scope_filter: Vec<ryo_suggest::SymbolScope> = request
1148 .scope_filter
1149 .iter()
1150 .filter_map(|s| s.parse().ok())
1151 .collect();
1152
1153 if request.scan {
1155 if request.precheck {
1156 let _ = self.suggest_scan_with_precheck();
1157 } else {
1158 let _ = self.suggest_scan_with_scope(&scope_filter);
1159 }
1160 }
1161
1162 let mut query = SuggestQuery::all();
1164
1165 if request.high_impact {
1167 query = query.with_max_safety(SafetyLevel::Auto);
1168 }
1169
1170 let results = self.suggest.query(&query);
1172
1173 let exclude_rules = &request.exclude_rules;
1175 let suggestions: Vec<Suggestion> = results
1176 .iter()
1177 .filter_map(|(id, category, safety)| {
1178 if !exclude_rules.is_empty() {
1180 if let Some(rule_id) = self.suggest.rule_id(*id) {
1181 if exclude_rules.iter().any(|ex| ex == rule_id) {
1182 return None;
1183 }
1184 }
1185 }
1186
1187 let stored = self.suggest.get(*id)?;
1188
1189 if !scope_filter.is_empty() && !scope_filter.contains(&stored.opportunity.scope) {
1191 return None;
1192 }
1193
1194 let pattern_name = self.suggest.pattern_name(*id)?;
1195 let rule_id = self.suggest.rule_id(*id).map(|s| s.to_string());
1196
1197 Some(Suggestion {
1198 id: id.to_string(),
1199 rule_id,
1200 title: pattern_name.to_string(),
1201 description: stored.opportunity.message.clone(),
1202 category: format!("{:?}", category),
1203 impact: format!("{:?}", safety),
1204 file: PathBuf::from(&stored.opportunity.location.file),
1205 symbol_path: Some(stored.opportunity.location.symbol_path.to_string()),
1206 fix_intent: None, })
1208 })
1209 .collect();
1210
1211 let mut suggestions = suggestions;
1213 suggestions.sort_by(|a, b| a.id.cmp(&b.id));
1214
1215 let total = suggestions.len();
1217 let high_impact = suggestions.iter().filter(|s| s.impact == "Auto").count();
1218
1219 let mut by_category = std::collections::HashMap::new();
1221 for s in &suggestions {
1222 *by_category.entry(s.category.clone()).or_insert(0) += 1;
1223 }
1224
1225 let enhanced = if request.enhanced {
1227 results
1228 .iter()
1229 .filter_map(|(id, _category, _safety)| {
1230 if !exclude_rules.is_empty() {
1232 if let Some(rule_id) = self.suggest.rule_id(*id) {
1233 if exclude_rules.iter().any(|ex| ex == rule_id) {
1234 return None;
1235 }
1236 }
1237 }
1238
1239 let stored = self.suggest.get(*id)?;
1240
1241 if !scope_filter.is_empty() && !scope_filter.contains(&stored.opportunity.scope)
1243 {
1244 return None;
1245 }
1246
1247 Some(
1248 EnhancedSuggestion::from_opportunity(&stored.opportunity, *id)
1249 .with_description(stored.opportunity.message.clone()),
1250 )
1251 })
1252 .collect()
1253 } else {
1254 Vec::new()
1255 };
1256
1257 Ok(SuggestResponse {
1258 suggestions,
1259 summary: SuggestionSummary {
1260 total,
1261 high_impact,
1262 by_category: by_category.into_iter().collect(),
1263 },
1264 enhanced,
1265 })
1266 }
1267
1268 pub fn suggest_scan(&self) -> usize {
1275 self.suggest_scan_with_scope(&[])
1276 }
1277
1278 pub fn suggest_scan_with_scope(&self, scope_filter: &[ryo_suggest::SymbolScope]) -> usize {
1283 use ryo_suggest::LintSeverity;
1284
1285 let all_symbols: Vec<SymbolId> = self.context.registry.iter().map(|(id, _)| id).collect();
1287
1288 let allow_store = ryo_suggest::AllowStore::from_context(&self.context);
1290
1291 let config = self.project.config();
1294 self.suggest.detect_with_config_and_scope(
1295 &self.context,
1296 &all_symbols,
1297 &allow_store,
1298 |rule_id, file_path| config.is_rule_enabled_for_file(file_path, rule_id),
1299 |rule_id| {
1300 self.suggest_config
1301 .get_severity_override(rule_id)
1302 .and_then(|s| s.parse::<LintSeverity>().ok())
1303 },
1304 scope_filter,
1305 )
1306 }
1307
1308 pub fn suggest_scan_with_precheck(&self) -> ryo_suggest::DetectWithPrecheckResult {
1321 use std::sync::atomic::{AtomicU64, Ordering};
1322
1323 static FORK_TIME: AtomicU64 = AtomicU64::new(0);
1325 static EXEC_TIME: AtomicU64 = AtomicU64::new(0);
1326 static GRAPH_REBUILD_TIME: AtomicU64 = AtomicU64::new(0);
1327 static VERIFY_TIME: AtomicU64 = AtomicU64::new(0);
1328 static COUNT: AtomicU64 = AtomicU64::new(0);
1329
1330 FORK_TIME.store(0, Ordering::Relaxed);
1332 EXEC_TIME.store(0, Ordering::Relaxed);
1333 GRAPH_REBUILD_TIME.store(0, Ordering::Relaxed);
1334 VERIFY_TIME.store(0, Ordering::Relaxed);
1335 COUNT.store(0, Ordering::Relaxed);
1336
1337 let all_symbols: Vec<SymbolId> = self.context.registry.iter().map(|(id, _)| id).collect();
1339
1340 let wall_start = Instant::now();
1342
1343 thread_local! {
1349 static CACHED_CTX: RefCell<Option<AnalysisContext>> = const { RefCell::new(None) };
1350 }
1351
1352 const PRECHECK_LIMIT: usize = 100;
1354
1355 let result = self.suggest.detect_with_parallel_precheck_and_rule_filter(
1357 &self.context,
1358 &all_symbols,
1359 Some(PRECHECK_LIMIT),
1360 |rule_id| self.suggest_config.is_rule_enabled(rule_id),
1361 |opp, specs, ctx| {
1362 COUNT.fetch_add(1, Ordering::Relaxed);
1363
1364 CACHED_CTX.with(|cell| {
1365 let mut borrow = cell.borrow_mut();
1366
1367 let forked_ctx = borrow.get_or_insert_with(|| {
1369 let t0 = Instant::now();
1370 let cloned = ctx.fork_clone();
1371 FORK_TIME.fetch_add(t0.elapsed().as_micros() as u64, Ordering::Relaxed);
1372 cloned
1373 });
1374
1375 let target_ids = &opp.targets;
1377
1378 let t_snapshot = Instant::now();
1380 let snapshot = forked_ctx.snapshot_symbols(target_ids);
1381 FORK_TIME.fetch_add(t_snapshot.elapsed().as_micros() as u64, Ordering::Relaxed);
1383
1384 let t1 = Instant::now();
1386 let blueprint = ParallelBlueprint::from_mutations(specs.to_vec());
1387 let executor = BlueprintExecutor::default();
1388 let exec_result = executor.execute_v2(&blueprint, forked_ctx);
1389 EXEC_TIME.fetch_add(t1.elapsed().as_micros() as u64, Ordering::Relaxed);
1390
1391 if !exec_result.success {
1392 forked_ctx.rollback(snapshot, target_ids);
1394 return false;
1395 }
1396
1397 let t_rebuild = Instant::now();
1400 let all_events: Vec<_> = exec_result
1401 .results
1402 .iter()
1403 .flat_map(|r| r.events.clone())
1404 .collect();
1405 let affected_ids: Vec<SymbolId> = if !all_events.is_empty() {
1406 let ids = collect_affected_ids(&all_events, forked_ctx.registry());
1407 forked_ctx.rebuild_after_mutation_by_symbols(&ids);
1408 ids
1409 } else {
1410 target_ids.clone()
1411 };
1412 GRAPH_REBUILD_TIME
1413 .fetch_add(t_rebuild.elapsed().as_micros() as u64, Ordering::Relaxed);
1414
1415 let t2 = Instant::now();
1417 let verifier = GraphVerifier::new();
1418 let vresult = verifier.verify_precheck(forked_ctx);
1419 VERIFY_TIME.fetch_add(t2.elapsed().as_micros() as u64, Ordering::Relaxed);
1420
1421 let t_rollback = Instant::now();
1423 forked_ctx.rollback(snapshot, &affected_ids);
1424 GRAPH_REBUILD_TIME
1426 .fetch_add(t_rollback.elapsed().as_micros() as u64, Ordering::Relaxed);
1427
1428 vresult.is_success()
1429 })
1430 },
1431 );
1432
1433 let wall_elapsed = wall_start.elapsed();
1435
1436 let count = COUNT.load(Ordering::Relaxed);
1438 if count > 0 {
1439 let cpu_total = (FORK_TIME.load(Ordering::Relaxed)
1440 + EXEC_TIME.load(Ordering::Relaxed)
1441 + GRAPH_REBUILD_TIME.load(Ordering::Relaxed)
1442 + VERIFY_TIME.load(Ordering::Relaxed)) as f64
1443 / 1000.0;
1444 let wall_ms = wall_elapsed.as_millis() as f64;
1445 let parallelism = if wall_ms > 0.0 {
1446 cpu_total / wall_ms
1447 } else {
1448 0.0
1449 };
1450
1451 let timing = format!(
1452 "\n=== Precheck Timing ({} suggestions) ===\n\
1453 fork_clone: {:>8.2}ms total, {:>6.2}ms avg\n\
1454 execute_v2: {:>8.2}ms total, {:>6.2}ms avg\n\
1455 graph_rebuild: {:>8.2}ms total, {:>6.2}ms avg\n\
1456 verify: {:>8.2}ms total, {:>6.2}ms avg\n\
1457 ---\n\
1458 CPU TOTAL: {:>8.2}ms\n\
1459 WALL CLOCK: {:>8.2}ms\n\
1460 PARALLELISM: {:>8.2}x (effective threads)\n\
1461 =====================================\n",
1462 count,
1463 FORK_TIME.load(Ordering::Relaxed) as f64 / 1000.0,
1464 FORK_TIME.load(Ordering::Relaxed) as f64 / 1000.0 / count as f64,
1465 EXEC_TIME.load(Ordering::Relaxed) as f64 / 1000.0,
1466 EXEC_TIME.load(Ordering::Relaxed) as f64 / 1000.0 / count as f64,
1467 GRAPH_REBUILD_TIME.load(Ordering::Relaxed) as f64 / 1000.0,
1468 GRAPH_REBUILD_TIME.load(Ordering::Relaxed) as f64 / 1000.0 / count as f64,
1469 VERIFY_TIME.load(Ordering::Relaxed) as f64 / 1000.0,
1470 VERIFY_TIME.load(Ordering::Relaxed) as f64 / 1000.0 / count as f64,
1471 cpu_total,
1472 wall_ms,
1473 parallelism,
1474 );
1475 let _ = std::fs::write("/tmp/precheck_timing.txt", &timing);
1476 }
1477
1478 result
1479 }
1480
1481 pub fn suggest_service(&self) -> &SuggestService {
1483 &self.suggest
1484 }
1485
1486 pub fn suggest_config(&self) -> &SuggestConfig {
1488 &self.suggest_config
1489 }
1490
1491 pub fn take_suggest_store(&self) -> ryo_suggest::SuggestStore {
1495 self.suggest.take_store()
1496 }
1497
1498 pub fn restore_suggest_store(&self, store: ryo_suggest::SuggestStore) {
1500 self.suggest.restore_store(store);
1501 }
1502
1503 pub fn suggest_apply(
1522 &mut self,
1523 request: SuggestApplyRequest,
1524 ) -> ApiResult<SuggestApplyResponse> {
1525 let mut response = SuggestApplyResponse::default();
1526 let mut all_specs = Vec::new();
1527 let mut valid_ids = Vec::new();
1528
1529 for id_str in &request.ids {
1531 let suggest_id: SuggestId = match id_str.parse() {
1533 Ok(id) => id,
1534 Err(e) => {
1535 response
1536 .failed
1537 .push((id_str.clone(), format!("Invalid ID format: {}", e)));
1538 continue;
1539 }
1540 };
1541
1542 if !self.suggest.is_valid(suggest_id) {
1544 response.failed.push((
1545 id_str.clone(),
1546 "Suggestion not found or expired".to_string(),
1547 ));
1548 continue;
1549 }
1550
1551 match self.suggest.to_mutation_specs(suggest_id, &self.context) {
1553 Some(specs) if !specs.is_empty() => {
1554 all_specs.extend(specs);
1555 valid_ids.push((id_str.clone(), suggest_id));
1556 }
1557 Some(_) => {
1558 response
1559 .failed
1560 .push((id_str.clone(), "No mutations generated".to_string()));
1561 }
1562 None => {
1563 response
1564 .failed
1565 .push((id_str.clone(), "Failed to generate mutations".to_string()));
1566 }
1567 }
1568 }
1569
1570 if all_specs.is_empty() {
1572 if response.failed.is_empty() {
1573 response.error = Some("No suggestions to apply".to_string());
1574 }
1575 return Ok(response);
1576 }
1577
1578 if request.dry_run {
1580 let mut forked_ctx = self.context.fork_clone();
1581 let blueprint = ParallelBlueprint::from_mutations(all_specs);
1582 let executor = BlueprintExecutor::default();
1583 let exec_result = executor.execute_v2(&blueprint, &mut forked_ctx);
1584
1585 if !exec_result.success {
1586 response.error = exec_result.error.clone();
1587 for (id_str, _) in &valid_ids {
1588 response.failed.push((
1589 id_str.clone(),
1590 exec_result
1591 .error
1592 .clone()
1593 .unwrap_or_else(|| "Execution failed".to_string()),
1594 ));
1595 }
1596 return Ok(response);
1597 }
1598
1599 response.success = true;
1600 response.total_changes = exec_result.total_changes;
1601 response.files_modified = exec_result.modified_files.len();
1602 response.modified_files = exec_result
1603 .modified_files
1604 .iter()
1605 .map(|p| p.to_absolute())
1606 .collect();
1607 for (id_str, _) in &valid_ids {
1608 response.applied_ids.push(id_str.clone());
1609 }
1610 return Ok(response);
1611 }
1612
1613 let blueprint = ParallelBlueprint::from_mutations(all_specs);
1615 let executor = BlueprintExecutor::default();
1616 let exec_result = executor.execute_v2(&blueprint, &mut self.context);
1617
1618 if !exec_result.success {
1619 response.error = exec_result.error.clone();
1620 for (id_str, _) in &valid_ids {
1621 response.failed.push((
1622 id_str.clone(),
1623 exec_result
1624 .error
1625 .clone()
1626 .unwrap_or_else(|| "Execution failed".to_string()),
1627 ));
1628 }
1629 return Ok(response);
1630 }
1631
1632 let modified_workspace_paths =
1634 BlueprintExecutor::sync_files_and_rebuild(&exec_result, &mut self.context)?;
1635
1636 response.success = true;
1638 response.total_changes = exec_result.total_changes;
1639
1640 let modified_files: Vec<PathBuf> = modified_workspace_paths
1642 .iter()
1643 .map(|p| p.to_absolute())
1644 .collect();
1645 response.files_modified = modified_files.len();
1646 response.modified_files = modified_files.clone();
1647
1648 for (id_str, _) in &valid_ids {
1650 response.applied_ids.push(id_str.clone());
1651 }
1652
1653 if exec_result.total_changes > 0 {
1657 for workspace_path in &modified_workspace_paths {
1658 if let Some(file) = self.context.files.get(workspace_path) {
1659 let source = match file.to_source() {
1660 Ok(s) => s,
1661 Err(e) => {
1662 response.syntax_errors.push(format!(
1663 "Failed to generate source for {}: {}",
1664 workspace_path.to_absolute().display(),
1665 e
1666 ));
1667 continue;
1668 }
1669 };
1670 if let Err(e) = workspace_path.write(&source) {
1672 response.syntax_errors.push(format!(
1673 "Failed to write {}: {}",
1674 workspace_path.to_absolute().display(),
1675 e
1676 ));
1677 }
1678 }
1679 }
1680 }
1681
1682 for (_, suggest_id) in &valid_ids {
1684 self.suggest.close(*suggest_id, "Applied via suggest_apply");
1685 }
1686
1687 if request.check_syntax {
1689 for workspace_path in &modified_workspace_paths {
1690 if let Some(file) = self.context.files.get(workspace_path) {
1691 let source = match file.to_source() {
1692 Ok(s) => s,
1693 Err(e) => {
1694 response.syntax_errors.push(format!(
1695 "{}: source generation failed: {}",
1696 workspace_path.to_absolute().display(),
1697 e
1698 ));
1699 continue;
1700 }
1701 };
1702 if let Err(e) = syn::parse_file(&source) {
1703 let abs_path = workspace_path.to_absolute();
1704 response
1705 .syntax_errors
1706 .push(format!("{}: {}", abs_path.display(), e));
1707 }
1708 }
1709 }
1710 }
1711
1712 Ok(response)
1713 }
1714
1715 pub fn graph_summary(&self, request: GraphSummaryRequest) -> ApiResult<GraphSummaryResponse> {
1724 use ryo_analysis::SymbolKind;
1725
1726 let registry = &self.context.registry;
1727 let max_items = request.max_items.unwrap_or(50);
1728
1729 let mut functions = 0usize;
1731 let mut structs = 0usize;
1732 let mut enums = 0usize;
1733 let mut traits = 0usize;
1734 let mut impls = 0usize;
1735
1736 for (id, _) in registry.iter() {
1737 if let Some(kind) = registry.kind(id) {
1738 match kind {
1739 SymbolKind::Function => functions += 1,
1740 SymbolKind::Struct => structs += 1,
1741 SymbolKind::Enum => enums += 1,
1742 SymbolKind::Trait => traits += 1,
1743 SymbolKind::Impl => impls += 1,
1744 _ => {}
1745 }
1746 }
1747 }
1748
1749 let node_count = self.context.code_graph.node_count();
1750 let edge_count = self.context.code_graph.edge_count();
1751 let file_count = self.context.files.len();
1752
1753 let mut out = String::new();
1755 out.push_str("=== CodeGraph ===\n");
1756
1757 if request.detailed {
1758 out.push_str(&format!("Nodes: {}\n", node_count));
1759 out.push_str(&format!("Edges: {}\n", edge_count));
1760 out.push_str(&format!(
1761 "Types: {} functions, {} structs, {} enums, {} traits, {} impls\n",
1762 functions, structs, enums, traits, impls
1763 ));
1764 out.push_str(&format!("Files: {}\n", file_count));
1765 } else if !request.compact {
1766 out.push_str(&format!(
1767 "Nodes: {}, Edges: {}, Files: {}\n",
1768 node_count, edge_count, file_count
1769 ));
1770 }
1771
1772 out.push('\n');
1773
1774 let fn_iter: Vec<_> = registry
1776 .iter_by_kind(SymbolKind::Function)
1777 .take(max_items)
1778 .collect();
1779 if !fn_iter.is_empty() {
1780 out.push_str("[Functions]\n");
1781 for id in &fn_iter {
1782 let vis = registry.visibility(*id).map_or("", |v| {
1783 if matches!(v, ryo_analysis::Visibility::Public) {
1784 "pub "
1785 } else {
1786 ""
1787 }
1788 });
1789 let name = registry
1790 .resolve(*id)
1791 .map(|p| p.name().to_string())
1792 .unwrap_or_default();
1793 if request.compact {
1794 out.push_str(&format!(" {}fn {}\n", vis, name));
1795 } else {
1796 out.push_str(&format!(" {}fn {} [{:?}]\n", vis, name, id));
1797 }
1798 }
1799 if functions > max_items {
1800 out.push_str(&format!(" ... and {} more\n", functions - max_items));
1801 }
1802 out.push('\n');
1803 }
1804
1805 let struct_iter: Vec<_> = registry
1807 .iter_by_kind(SymbolKind::Struct)
1808 .take(max_items)
1809 .collect();
1810 if !struct_iter.is_empty() {
1811 out.push_str("[Structs]\n");
1812 for id in &struct_iter {
1813 let vis = registry.visibility(*id).map_or("", |v| {
1814 if matches!(v, ryo_analysis::Visibility::Public) {
1815 "pub "
1816 } else {
1817 ""
1818 }
1819 });
1820 let name = registry
1821 .resolve(*id)
1822 .map(|p| p.name().to_string())
1823 .unwrap_or_default();
1824 if request.compact {
1825 out.push_str(&format!(" {}struct {}\n", vis, name));
1826 } else {
1827 out.push_str(&format!(" {}struct {} [{:?}]\n", vis, name, id));
1828 }
1829 }
1830 if structs > max_items {
1831 out.push_str(&format!(" ... and {} more\n", structs - max_items));
1832 }
1833 out.push('\n');
1834 }
1835
1836 let enum_iter: Vec<_> = registry
1838 .iter_by_kind(SymbolKind::Enum)
1839 .take(max_items)
1840 .collect();
1841 if !enum_iter.is_empty() {
1842 out.push_str("[Enums]\n");
1843 for id in &enum_iter {
1844 let vis = registry.visibility(*id).map_or("", |v| {
1845 if matches!(v, ryo_analysis::Visibility::Public) {
1846 "pub "
1847 } else {
1848 ""
1849 }
1850 });
1851 let name = registry
1852 .resolve(*id)
1853 .map(|p| p.name().to_string())
1854 .unwrap_or_default();
1855 if request.compact {
1856 out.push_str(&format!(" {}enum {}\n", vis, name));
1857 } else {
1858 out.push_str(&format!(" {}enum {} [{:?}]\n", vis, name, id));
1859 }
1860 }
1861 out.push('\n');
1862 }
1863
1864 let trait_iter: Vec<_> = registry
1866 .iter_by_kind(SymbolKind::Trait)
1867 .take(max_items)
1868 .collect();
1869 if !trait_iter.is_empty() {
1870 out.push_str("[Traits]\n");
1871 for id in &trait_iter {
1872 let vis = registry.visibility(*id).map_or("", |v| {
1873 if matches!(v, ryo_analysis::Visibility::Public) {
1874 "pub "
1875 } else {
1876 ""
1877 }
1878 });
1879 let name = registry
1880 .resolve(*id)
1881 .map(|p| p.name().to_string())
1882 .unwrap_or_default();
1883 if request.compact {
1884 out.push_str(&format!(" {}trait {}\n", vis, name));
1885 } else {
1886 out.push_str(&format!(" {}trait {} [{:?}]\n", vis, name, id));
1887 }
1888 }
1889 out.push('\n');
1890 }
1891
1892 Ok(GraphSummaryResponse {
1893 content: out,
1894 build_time_ms: 0, node_count,
1896 edge_count,
1897 file_count,
1898 })
1899 }
1900
1901 pub fn graph_cascade(&self, request: CascadeRequest) -> ApiResult<CascadeResponse> {
1913 let _depth = request.depth.unwrap_or(3);
1914
1915 let symbol_id = self.resolve_symbol_id_required(request.uuid.as_deref(), &request.id)?;
1917
1918 let symbol_kind = self.context.registry.kind(symbol_id);
1919
1920 let callers: Vec<String> = self
1922 .context
1923 .code_graph
1924 .callers_of(symbol_id)
1925 .filter_map(|id| {
1926 self.context
1927 .registry
1928 .resolve(id)
1929 .map(|path| format!("{} [{:?}]", path, id))
1930 })
1931 .collect();
1932
1933 let users: Vec<String> = self
1935 .context
1936 .typeflow_graph
1937 .type_users(symbol_id)
1938 .filter_map(|id| {
1939 self.context
1940 .registry
1941 .resolve(id)
1942 .map(|path| format!("{} [{:?}]", path, id))
1943 })
1944 .collect();
1945
1946 let match_functions: Vec<String> = if symbol_kind == Some(SymbolKind::Enum) {
1950 let enum_name = self
1951 .context
1952 .registry
1953 .resolve(symbol_id)
1954 .map(|p| p.name().to_string())
1955 .unwrap_or_default();
1956 if enum_name.is_empty() {
1957 Vec::new()
1958 } else {
1959 self.find_match_functions_for_enum(&enum_name)
1960 }
1961 } else {
1962 Vec::new()
1963 };
1964
1965 let containing_types: Vec<String> = {
1967 let impact = self.context.typeflow_graph.impact(symbol_id);
1968 impact
1969 .containing_types
1970 .iter()
1971 .filter_map(|id| {
1972 self.context
1973 .registry
1974 .resolve(*id)
1975 .map(|path| format!("{} [{:?}]", path, id))
1976 })
1977 .collect()
1978 };
1979
1980 let display_name = self
1981 .context
1982 .registry
1983 .resolve(symbol_id)
1984 .map(|p| p.name().to_string())
1985 .unwrap_or_else(|| request.id.clone());
1986
1987 Ok(CascadeResponse {
1988 display_name,
1989 callers,
1990 users,
1991 match_functions,
1992 containing_types,
1993 })
1994 }
1995
1996 fn find_match_functions_for_enum(&self, enum_name: &str) -> Vec<String> {
1998 use crate::discover::pattern_contains_enum;
1999 use ryo_source::pure::{PureExpr, PureImplItem, PureItem, PureStmt};
2000
2001 let mut results = Vec::new();
2002
2003 for (_path, file) in self.context.files().iter() {
2004 let wfp = _path;
2005 let crate_name = wfp.crate_name();
2006 let resolver = ryo_symbol::SymbolPathResolver::new(crate_name.as_str());
2007 let mod_path = resolver.module_path_str(wfp);
2008
2009 for item in &file.items {
2010 match item {
2011 PureItem::Fn(func) if block_has_match_on_enum(&func.body, enum_name) => {
2012 let fn_path = format!("{}::{}", mod_path, func.name);
2013 results.push(fn_path);
2014 }
2015 PureItem::Impl(impl_block) => {
2016 let type_name = &impl_block.self_ty;
2017 for impl_item in &impl_block.items {
2018 if let PureImplItem::Fn(func) = impl_item {
2019 if block_has_match_on_enum(&func.body, enum_name) {
2020 let fn_path =
2021 format!("{}::{}::{}", mod_path, type_name, func.name);
2022 results.push(fn_path);
2023 }
2024 }
2025 }
2026 }
2027 _ => {}
2028 }
2029 }
2030 }
2031
2032 fn block_has_match_on_enum(block: &ryo_source::pure::PureBlock, enum_name: &str) -> bool {
2034 for stmt in &block.stmts {
2035 let expr = match stmt {
2036 PureStmt::Expr(e) | PureStmt::Semi(e) => Some(e),
2037 PureStmt::Local { init: Some(e), .. } => Some(e),
2038 _ => None,
2039 };
2040 if let Some(expr) = expr {
2041 if expr_has_match_on_enum(expr, enum_name) {
2042 return true;
2043 }
2044 }
2045 }
2046 false
2047 }
2048
2049 fn expr_has_match_on_enum(expr: &PureExpr, enum_name: &str) -> bool {
2051 match expr {
2052 PureExpr::Match { arms, expr: inner } => {
2053 let has_enum = arms
2054 .iter()
2055 .any(|arm| pattern_contains_enum(&arm.pattern, enum_name));
2056 if has_enum {
2057 return true;
2058 }
2059 expr_has_match_on_enum(inner, enum_name)
2060 }
2061 PureExpr::Block { block, .. }
2062 | PureExpr::Unsafe(block)
2063 | PureExpr::Async { body: block, .. }
2064 | PureExpr::Loop { body: block, .. } => block_has_match_on_enum(block, enum_name),
2065 PureExpr::If {
2066 cond,
2067 then_branch,
2068 else_branch,
2069 } => {
2070 expr_has_match_on_enum(cond, enum_name)
2071 || block_has_match_on_enum(then_branch, enum_name)
2072 || else_branch
2073 .as_ref()
2074 .is_some_and(|e| expr_has_match_on_enum(e, enum_name))
2075 }
2076 _ => false,
2077 }
2078 }
2079
2080 results
2081 }
2082
2083 pub fn graph_type(&self, request: TypeAnalysisRequest) -> ApiResult<TypeAnalysisResponse> {
2088 use types::{TypeAnalysisMode as ApiMode, TypeImpactInfo, TypeUsageInfo};
2089
2090 let (symbol_id, display_name) = if let Some(sid) =
2092 self.resolve_symbol_id(request.uuid.as_deref(), request.id.as_deref(), None)?
2093 {
2094 let name = self
2095 .context
2096 .registry
2097 .resolve(sid)
2098 .map(|p| p.name().to_string())
2099 .unwrap_or_else(|| format!("{:?}", sid));
2100 (sid, name)
2101 } else if let Some(ref name) = request.name {
2102 let symbol_ids = self.context.registry.find_by_name(name);
2103 match symbol_ids.len() {
2104 0 => return Err(ApiError::NotFound(name.clone())),
2105 1 => (symbol_ids[0], name.clone()),
2106 _ => {
2107 let candidates: Vec<String> = symbol_ids
2109 .iter()
2110 .filter_map(|&sid| {
2111 let path = self.context.registry.resolve(sid)?;
2112 let kind = self.context.registry.kind(sid)?;
2113 Some(format!(" {} ({:?}) --id {}", path, kind, sid))
2114 })
2115 .collect();
2116 return Err(ApiError::InvalidGoal(format!(
2117 "Ambiguous name '{}': {} symbols found. Use --id to specify:\n{}",
2118 name,
2119 symbol_ids.len(),
2120 candidates.join("\n")
2121 )));
2122 }
2123 }
2124 } else {
2125 return Err(ApiError::InvalidGoal(
2126 "Either 'name', 'id', or 'uuid' must be provided".to_string(),
2127 ));
2128 };
2129
2130 let typeflow = &self.context.typeflow_graph;
2131 let registry = &self.context.registry;
2132
2133 let mod_path = registry.resolve(symbol_id).map(|p| p.to_string());
2135 let kind = typeflow.definition(symbol_id).and_then(|def_node| {
2136 typeflow
2137 .get_definition(def_node)
2138 .map(|d| format!("{:?}", d.kind))
2139 });
2140
2141 let usage_count = typeflow.usages(symbol_id).count();
2143
2144 let usage_infos: Vec<TypeUsageInfo> = match request.mode {
2146 ApiMode::Usage | ApiMode::Impact => typeflow
2147 .usages(symbol_id)
2148 .take(20)
2149 .filter_map(|usage_node| {
2150 typeflow.get_usage(usage_node).map(|u| TypeUsageInfo {
2151 context: format!("{:?}", u.context),
2152 ref_kind: format!("{:?}", u.ref_kind),
2153 container: u.container.and_then(|cid| {
2154 self.context.registry.resolve(cid).map(|p| p.to_string())
2155 }),
2156 })
2157 })
2158 .collect(),
2159 ApiMode::Definition => Vec::new(),
2160 };
2161
2162 let impact = match request.mode {
2164 ApiMode::Impact => {
2165 let impact = typeflow.impact(symbol_id);
2166 let containing: Vec<String> = impact
2167 .containing_types
2168 .iter()
2169 .take(10)
2170 .filter_map(|&id| registry.resolve(id).map(|p| format!("{} [{:?}]", p, id)))
2171 .collect();
2172 Some(TypeImpactInfo {
2173 direct_usages: impact.direct_usages.len(),
2174 bound_usages: impact.bound_usages.len(),
2175 containing_types: containing,
2176 })
2177 }
2178 _ => None,
2179 };
2180
2181 let (fields, variants, params, return_type, methods, generics, attrs) = match request.mode {
2183 ApiMode::Definition => self.collect_definition_details(symbol_id),
2184 _ => Default::default(),
2185 };
2186
2187 let supertraits = match request.mode {
2189 ApiMode::Definition => self
2190 .context
2191 .detail_store
2192 .trait_(symbol_id)
2193 .map(|t| t.supertraits.clone())
2194 .unwrap_or_default(),
2195 _ => Vec::new(),
2196 };
2197
2198 let implementors: Vec<String> = match request.mode {
2200 ApiMode::Definition => self
2201 .context
2202 .code_graph
2203 .implementors_of(symbol_id)
2204 .take(20)
2205 .filter_map(|impl_id| {
2206 registry
2207 .resolve(impl_id)
2208 .map(|p| format!("{} [{:?}]", p, impl_id))
2209 })
2210 .collect(),
2211 _ => Vec::new(),
2212 };
2213
2214 Ok(TypeAnalysisResponse {
2215 symbol_id: format!("{:?}", symbol_id),
2216 display_name,
2217 mod_path,
2218 kind,
2219 usage_count,
2220 usages: usage_infos,
2221 impact,
2222 supertraits,
2223 implementors,
2224 fields,
2225 variants,
2226 params,
2227 return_type,
2228 methods,
2229 generics,
2230 attrs,
2231 })
2232 }
2233
2234 fn collect_definition_details(&self, symbol_id: ryo_analysis::SymbolId) -> DefinitionDetails {
2236 let ds = &self.context.detail_store;
2237
2238 if let Some(detail) = ds.struct_(symbol_id) {
2240 let fields = detail
2241 .fields
2242 .iter()
2243 .map(|f| types::TypeFieldInfo {
2244 name: f.name.clone(),
2245 ty: f.ty.clone(),
2246 is_public: f.is_public,
2247 })
2248 .collect();
2249 let generics = if detail.generics.is_empty() {
2250 None
2251 } else {
2252 Some(format_generics(&detail.generics))
2253 };
2254 return (
2255 fields,
2256 Vec::new(),
2257 Vec::new(),
2258 None,
2259 Vec::new(),
2260 generics,
2261 detail
2262 .attrs
2263 .iter()
2264 .filter(|a| !a.starts_with("doc"))
2265 .cloned()
2266 .collect(),
2267 );
2268 }
2269
2270 if let Some(detail) = ds.enum_(symbol_id) {
2272 let variants = detail
2273 .variants
2274 .iter()
2275 .map(|v| types::TypeVariantInfo {
2276 name: v.name.clone(),
2277 fields: v
2278 .fields
2279 .iter()
2280 .map(|f| types::TypeFieldInfo {
2281 name: f.name.clone(),
2282 ty: f.ty.clone(),
2283 is_public: f.is_public,
2284 })
2285 .collect(),
2286 })
2287 .collect();
2288 let generics = if detail.generics.is_empty() {
2289 None
2290 } else {
2291 Some(format_generics(&detail.generics))
2292 };
2293 return (
2294 Vec::new(),
2295 variants,
2296 Vec::new(),
2297 None,
2298 Vec::new(),
2299 generics,
2300 detail
2301 .attrs
2302 .iter()
2303 .filter(|a| !a.starts_with("doc"))
2304 .cloned()
2305 .collect(),
2306 );
2307 }
2308
2309 if let Some(detail) = ds.function(symbol_id) {
2311 let params = detail
2312 .params
2313 .iter()
2314 .filter(|p| !p.is_self)
2315 .map(|p| types::TypeParamInfo {
2316 name: p.name.clone(),
2317 ty: p.ty.clone(),
2318 })
2319 .collect();
2320 let generics = if detail.generics.is_empty() {
2321 None
2322 } else {
2323 Some(format_generics(&detail.generics))
2324 };
2325 return (
2326 Vec::new(),
2327 Vec::new(),
2328 params,
2329 detail.return_type.clone(),
2330 Vec::new(),
2331 generics,
2332 detail
2333 .attrs
2334 .iter()
2335 .filter(|a| !a.starts_with("doc"))
2336 .cloned()
2337 .collect(),
2338 );
2339 }
2340
2341 if let Some(detail) = ds.trait_(symbol_id) {
2343 let generics = if detail.generics.is_empty() {
2344 None
2345 } else {
2346 Some(format_generics(&detail.generics))
2347 };
2348 return (
2349 Vec::new(),
2350 Vec::new(),
2351 Vec::new(),
2352 None,
2353 detail.methods.clone(),
2354 generics,
2355 detail
2356 .attrs
2357 .iter()
2358 .filter(|a| !a.starts_with("doc"))
2359 .cloned()
2360 .collect(),
2361 );
2362 }
2363
2364 Default::default()
2365 }
2366
2367 pub fn graph_flow(&self, request: FlowAnalysisRequest) -> ApiResult<FlowAnalysisResponse> {
2372 use ryo_analysis::VarId;
2373 use types::{FlowAnalysisMode as ApiMode, VarInfo};
2374
2375 let dataflow = &self.context.dataflow_graph;
2376 let registry = &self.context.registry;
2377
2378 let var_to_info = |var_id: VarId| -> VarInfo {
2380 let name = dataflow.var_name(var_id).unwrap_or("unknown").to_string();
2381 let (kind, parent) = dataflow
2382 .var(var_id)
2383 .map(|d| {
2384 let kind = format!("{:?}", d.kind);
2385 let parent = registry
2386 .resolve(d.parent)
2387 .map(|p| format!("{} ({:?})", p.name(), d.parent))
2388 .unwrap_or_else(|| format!("{:?}", d.parent));
2389 (kind, parent)
2390 })
2391 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
2392 let symbol_path = dataflow
2393 .var_to_symbol(var_id)
2394 .and_then(|s| registry.resolve(s))
2395 .map(|p| p.to_string());
2396 VarInfo {
2397 name,
2398 kind,
2399 parent,
2400 symbol_path,
2401 }
2402 };
2403
2404 let symbol_id =
2405 self.resolve_symbol_id(request.uuid.as_deref(), request.id.as_deref(), None)?;
2406 let (matching_vars, display_name) = dataflow
2407 .resolve_vars(symbol_id, request.name.as_deref(), registry)
2408 .ok_or_else(|| {
2409 ApiError::InvalidGoal("Either 'name', 'id', or 'uuid' must be provided".to_string())
2410 })?;
2411
2412 if matching_vars.is_empty() {
2413 return Err(self.no_vars_error(request.name.as_deref(), &display_name, registry));
2414 }
2415
2416 let found_vars: Vec<VarInfo> = matching_vars
2417 .iter()
2418 .take(5)
2419 .map(|&v| var_to_info(v))
2420 .collect();
2421
2422 let mut provenance = Vec::new();
2423 let mut impact = Vec::new();
2424 let mut sources = Vec::new();
2425 let mut sinks = Vec::new();
2426
2427 match request.mode {
2428 ApiMode::Provenance | ApiMode::Chain => {
2429 for &var_id in matching_vars.iter().take(3) {
2430 for prov_id in dataflow.provenance(var_id).into_iter().take(5) {
2431 provenance.push(var_to_info(prov_id));
2432 }
2433 }
2434 }
2435 _ => {}
2436 }
2437
2438 match request.mode {
2439 ApiMode::Impact | ApiMode::Chain => {
2440 for &var_id in matching_vars.iter().take(3) {
2441 for impact_id in dataflow.impact(var_id).into_iter().take(5) {
2442 impact.push(var_to_info(impact_id));
2443 }
2444 }
2445 }
2446 _ => {}
2447 }
2448
2449 if matches!(request.mode, ApiMode::Sources) {
2450 sources = dataflow
2451 .iter_vars()
2452 .filter(|(var_id, _)| dataflow.incoming(*var_id).is_empty())
2453 .take(20)
2454 .map(|(var_id, _)| var_to_info(var_id))
2455 .collect();
2456 }
2457
2458 if matches!(request.mode, ApiMode::Sinks) {
2459 sinks = dataflow
2460 .iter_vars()
2461 .filter(|(var_id, _)| dataflow.outgoing(*var_id).is_empty())
2462 .take(20)
2463 .map(|(var_id, _)| var_to_info(var_id))
2464 .collect();
2465 }
2466
2467 Ok(FlowAnalysisResponse {
2468 display_name,
2469 found_vars,
2470 provenance,
2471 impact,
2472 sources,
2473 sinks,
2474 })
2475 }
2476
2477 pub fn graph_borrow(
2482 &self,
2483 request: BorrowAnalysisRequest,
2484 ) -> ApiResult<BorrowAnalysisResponse> {
2485 use ryo_analysis::BorrowKind;
2486 use types::BorrowStatus;
2487
2488 let dataflow = &self.context.dataflow_graph;
2489 let registry = &self.context.registry;
2490 let tracker = dataflow.borrow_tracker();
2491
2492 let symbol_id =
2493 self.resolve_symbol_id(request.uuid.as_deref(), request.id.as_deref(), None)?;
2494 let (matching_vars, display_name) = dataflow
2495 .resolve_vars(symbol_id, request.name.as_deref(), registry)
2496 .ok_or_else(|| {
2497 ApiError::InvalidGoal("Either 'name', 'id', or 'uuid' must be provided".to_string())
2498 })?;
2499
2500 if matching_vars.is_empty() {
2501 return Err(self.no_vars_error(request.name.as_deref(), &display_name, registry));
2502 }
2503
2504 let mut statuses = Vec::new();
2505
2506 for &var_id in matching_vars.iter().take(10) {
2507 let var_data = dataflow.var(var_id);
2508 let var_name = dataflow.var_name(var_id).unwrap_or("unknown").to_string();
2509 let line = var_data.map(|d| d.line).unwrap_or(0);
2510
2511 let parent_info = var_data
2512 .and_then(|d| {
2513 registry
2514 .resolve(d.parent)
2515 .map(|p| format!("{} ({:?})", p.name(), d.parent))
2516 })
2517 .unwrap_or_default();
2518
2519 let mut_conflicts = tracker.conflicts(var_id, BorrowKind::Mutable, line);
2520 let shared_conflicts = tracker.conflicts(var_id, BorrowKind::Shared, line);
2521
2522 let has_conflict = !mut_conflicts.is_empty() || !shared_conflicts.is_empty();
2523 let errors: Vec<String> = mut_conflicts
2524 .iter()
2525 .chain(shared_conflicts.iter())
2526 .map(|c| format!("`{}`: {}", var_name, c))
2527 .collect();
2528
2529 if !request.conflicts_only || has_conflict {
2530 statuses.push(BorrowStatus {
2531 var_name,
2532 line,
2533 parent_info,
2534 has_conflict,
2535 errors,
2536 });
2537 }
2538 }
2539
2540 Ok(BorrowAnalysisResponse {
2541 display_name,
2542 found_count: matching_vars.len(),
2543 statuses,
2544 })
2545 }
2546
2547 pub fn graph_lock(&self, request: LockAnalysisRequest) -> ApiResult<LockAnalysisResponse> {
2552 use ryo_analysis::LockGranularityAnalyzerV2;
2553 use types::{LockAcquisition, LockStats, LockSuggestionInfo};
2554
2555 let dataflow = &self.context.dataflow_graph;
2556 let registry = &self.context.registry;
2557 let tracker = dataflow.lock_tracker();
2558 let analyzer = LockGranularityAnalyzerV2::new(tracker);
2559
2560 let resolved_symbol_id =
2562 self.resolve_symbol_id(request.uuid.as_deref(), request.id.as_deref(), None)?;
2563
2564 let display_name = if let Some(symbol_id) = resolved_symbol_id {
2565 registry
2566 .resolve(symbol_id)
2567 .map(|p| p.name().to_string())
2568 .unwrap_or_else(|| format!("{:?}", symbol_id))
2569 } else if let Some(ref name) = request.name {
2570 name.clone()
2571 } else {
2572 return Err(ApiError::InvalidGoal(
2573 "Either 'name', 'id', or 'uuid' must be provided".to_string(),
2574 ));
2575 };
2576
2577 let stats_data = analyzer.stats();
2579 let stats = LockStats {
2580 total_locks: stats_data.total_locks as u32,
2581 mutex_count: stats_data.mutex_count as u32,
2582 rwlock_count: stats_data.rwlock_count as u32,
2583 refcell_count: stats_data.refcell_count as u32,
2584 total_field_accesses: stats_data.total_field_accesses as u32,
2585 max_cs_span: stats_data.max_cs_span,
2586 };
2587
2588 let acquisitions: Vec<LockAcquisition> = if let Some(symbol_id) = resolved_symbol_id {
2590 tracker
2591 .acquisitions_by_owner(symbol_id)
2592 .into_iter()
2593 .take(10)
2594 .map(|acq| LockAcquisition {
2595 lock_name: acq.lock_name.clone(),
2596 lock_type: format!("{:?}", acq.lock_type),
2597 line: acq.line,
2598 })
2599 .collect()
2600 } else {
2601 tracker
2602 .acquisitions()
2603 .iter()
2604 .filter(|acq| matches_lock_pattern_server(acq, &display_name))
2605 .take(10)
2606 .map(|acq| LockAcquisition {
2607 lock_name: acq.lock_name.clone(),
2608 lock_type: format!("{:?}", acq.lock_type),
2609 line: acq.line,
2610 })
2611 .collect()
2612 };
2613
2614 let suggestions = if request.suggest {
2616 analyzer
2617 .analyze()
2618 .into_iter()
2619 .take(10)
2620 .map(|s| {
2621 use ryo_analysis::LockSuggestion;
2622 match s {
2623 LockSuggestion::UseAtomic {
2624 field,
2625 suggested_type,
2626 line,
2627 ..
2628 } => LockSuggestionInfo {
2629 kind: "UseAtomic".to_string(),
2630 target: field,
2631 description: suggested_type,
2632 line,
2633 },
2634 LockSuggestion::SplitLock {
2635 lock_name,
2636 suggested_splits,
2637 line,
2638 } => LockSuggestionInfo {
2639 kind: "SplitLock".to_string(),
2640 target: lock_name,
2641 description: format!("Split into {} parts", suggested_splits.len()),
2642 line,
2643 },
2644 LockSuggestion::ReduceScope {
2645 guard_name,
2646 current_span,
2647 ..
2648 } => LockSuggestionInfo {
2649 kind: "ReduceScope".to_string(),
2650 target: guard_name,
2651 description: format!("lines {}-{}", current_span.0, current_span.1),
2652 line: current_span.0,
2653 },
2654 LockSuggestion::UseRwLock {
2655 lock_name, line, ..
2656 } => LockSuggestionInfo {
2657 kind: "UseRwLock".to_string(),
2658 target: lock_name,
2659 description: "Consider RwLock".to_string(),
2660 line,
2661 },
2662 LockSuggestion::LockAcrossAwait {
2663 guard_name,
2664 lock_line,
2665 await_line,
2666 ..
2667 } => LockSuggestionInfo {
2668 kind: "LockAcrossAwait".to_string(),
2669 target: guard_name,
2670 description: format!("await at line {}", await_line),
2671 line: lock_line,
2672 },
2673 LockSuggestion::RemoveLock {
2674 lock_name,
2675 reason,
2676 line,
2677 } => LockSuggestionInfo {
2678 kind: "RemoveLock".to_string(),
2679 target: lock_name,
2680 description: reason,
2681 line,
2682 },
2683 }
2684 })
2685 .collect()
2686 } else {
2687 Vec::new()
2688 };
2689
2690 Ok(LockAnalysisResponse {
2691 display_name,
2692 stats,
2693 acquisitions,
2694 suggestions,
2695 })
2696 }
2697
2698 pub fn graph_chain(&self, request: ChainAnalysisRequest) -> ApiResult<ChainAnalysisResponse> {
2709 use ryo_analysis::ChainDirection;
2710 use std::collections::BTreeMap;
2711
2712 let symbol_id = self.resolve_symbol_id_required(request.uuid.as_deref(), &request.id)?;
2714
2715 let max_depth = request.depth.unwrap_or(5);
2716
2717 let result = match request.mode {
2719 types::ChainMode::Callers => {
2720 self.context
2721 .code_graph
2722 .analyze_chain(symbol_id, max_depth, ChainDirection::Callers)
2723 }
2724 types::ChainMode::Callees => {
2725 self.context
2726 .code_graph
2727 .analyze_chain(symbol_id, max_depth, ChainDirection::Callees)
2728 }
2729 types::ChainMode::TypeUsers => self.context.typeflow_graph.analyze_type_chain(
2730 symbol_id,
2731 max_depth,
2732 ChainDirection::TypeUsers,
2733 ),
2734 types::ChainMode::TypeDeps => self.context.typeflow_graph.analyze_type_chain(
2735 symbol_id,
2736 max_depth,
2737 ChainDirection::TypeDeps,
2738 ),
2739 };
2740
2741 let display_name = self
2743 .context
2744 .registry
2745 .resolve(symbol_id)
2746 .map(|p| p.to_string())
2747 .unwrap_or_else(|| format!("{:?}", symbol_id));
2748
2749 let mut by_depth: BTreeMap<usize, Vec<types::ChainNodeInfo>> = BTreeMap::new();
2751 for node in &result.nodes {
2752 let path = self
2753 .context
2754 .registry
2755 .resolve(node.symbol)
2756 .map(|p| p.to_string())
2757 .unwrap_or_default();
2758 let kind = self
2759 .context
2760 .registry
2761 .kind(node.symbol)
2762 .map(|k| format!("{:?}", k));
2763
2764 by_depth
2765 .entry(node.depth)
2766 .or_default()
2767 .push(types::ChainNodeInfo {
2768 id: format!("{:?}", node.symbol),
2769 path,
2770 kind,
2771 depth: node.depth,
2772 });
2773 }
2774
2775 Ok(ChainAnalysisResponse {
2776 display_name,
2777 direction: result.direction.to_string(),
2778 total_count: result.nodes.len(),
2779 max_actual_depth: result.max_actual_depth,
2780 by_depth,
2781 })
2782 }
2783
2784 pub fn status(&self) -> StatusResponse {
2786 StatusResponse {
2787 project: self.project.root().to_path_buf(),
2788 symbols: self.context.registry.len(),
2789 files: self.context.file_count(),
2790 }
2791 }
2792
2793 pub fn spec(&mut self, request: SpecRequest) -> ApiResult<SpecResponse> {
2804 use crate::spec::{LintSeverity, SpecService, SpecSourceKind};
2805
2806 if self.spec_cache.is_none() {
2808 self.spec_cache =
2809 Some(SpecService::from_context(&self.context).map_err(ApiError::Spec)?);
2810 }
2811 let data = self
2812 .spec_cache
2813 .as_ref()
2814 .expect("spec_cache was just initialized to Some above");
2815
2816 match request.query {
2817 SpecQueryKind::Show => {
2818 let show = data.to_show_response();
2819 Ok(SpecResponse::Show(SpecShowData {
2820 groups: show
2821 .groups
2822 .into_iter()
2823 .map(|g| SpecGroupData {
2824 name: g.name,
2825 specs: g
2826 .specs
2827 .into_iter()
2828 .map(|s| SpecInfoData {
2829 alias_name: s.alias_name,
2830 wrapped_type_name: s.wrapped_type_name,
2831 source: match s.source {
2832 SpecSourceKind::TypeAlias => "type_alias".to_string(),
2833 SpecSourceKind::Comment => "comment".to_string(),
2834 SpecSourceKind::Inferred => "inferred".to_string(),
2835 },
2836 })
2837 .collect(),
2838 })
2839 .collect(),
2840 relations: show
2841 .relations
2842 .into_iter()
2843 .map(|r| SpecRelationData {
2844 from: r.from,
2845 to: r.to,
2846 kind: format!("{:?}", r.kind).to_lowercase(),
2847 })
2848 .collect(),
2849 stats: SpecStatsData {
2850 groups: show.stats.groups,
2851 specs: show.stats.specs,
2852 nodes: show.stats.nodes,
2853 edges: show.stats.edges,
2854 },
2855 }))
2856 }
2857 SpecQueryKind::Groups => {
2858 let groups = data.group_names();
2859 Ok(SpecResponse::Groups(groups))
2860 }
2861 SpecQueryKind::TypesInGroup { group } => {
2862 let specs = data.specs_in_group(&group);
2863 Ok(SpecResponse::TypesInGroup(
2864 specs
2865 .into_iter()
2866 .map(|s| SpecInfoData {
2867 alias_name: s.alias_name,
2868 wrapped_type_name: s.wrapped_type_name,
2869 source: match s.source {
2870 SpecSourceKind::TypeAlias => "type_alias".to_string(),
2871 SpecSourceKind::Comment => "comment".to_string(),
2872 SpecSourceKind::Inferred => "inferred".to_string(),
2873 },
2874 })
2875 .collect(),
2876 ))
2877 }
2878 SpecQueryKind::Dependencies { ref type_name } => {
2879 tracing::warn!(
2881 type_name = %type_name,
2882 "spec dependencies query is not yet implemented (requires name->SpecNodeId lookup)"
2883 );
2884 Ok(SpecResponse::Dependencies(vec![]))
2885 }
2886 SpecQueryKind::Dependents { ref type_name } => {
2887 tracing::warn!(
2889 type_name = %type_name,
2890 "spec dependents query is not yet implemented (requires name->SpecNodeId lookup)"
2891 );
2892 Ok(SpecResponse::Dependents(vec![]))
2893 }
2894 SpecQueryKind::Stats => {
2895 let stats = data.stats();
2896 Ok(SpecResponse::Stats(SpecStatsData {
2897 groups: stats.groups,
2898 specs: stats.specs,
2899 nodes: stats.nodes,
2900 edges: stats.edges,
2901 }))
2902 }
2903 SpecQueryKind::Lint => {
2904 let lint = data.lint();
2905 Ok(SpecResponse::Lint(SpecLintData {
2906 issues: lint
2907 .issues
2908 .into_iter()
2909 .map(|i| SpecLintIssueData {
2910 severity: match i.severity {
2911 LintSeverity::Warning => "warning".to_string(),
2912 LintSeverity::Error => "error".to_string(),
2913 },
2914 message: i.message,
2915 location: i.location,
2916 })
2917 .collect(),
2918 warnings: lint.warnings,
2919 errors: lint.errors,
2920 }))
2921 }
2922 SpecQueryKind::Mermaid => {
2923 let mermaid = data.to_mermaid();
2924 Ok(SpecResponse::Mermaid(mermaid))
2925 }
2926 }
2927 }
2928
2929 pub fn run(&mut self, request: RunRequest) -> ApiResult<RunResponse> {
2945 let opts = ExecuteOptions {
2946 dry_run: request.dry_run,
2947 check_syntax: request.check_syntax,
2948 };
2949
2950 match self.execute_with_options(request.goal, opts) {
2951 Ok(result) => Ok(RunResponse {
2952 success: result.success,
2953 files_modified: result.files_modified,
2954 total_changes: result.total_changes,
2955 modified_files: result.modified_files,
2956 conflicts: result
2957 .conflicts
2958 .iter()
2959 .map(|c| format!("{:?}", c))
2960 .collect(),
2961 syntax_errors: result.syntax_errors,
2962 error: None,
2963 }),
2964 Err(e) => Ok(RunResponse {
2965 success: false,
2966 error: Some(e.to_string()),
2967 ..Default::default()
2968 }),
2969 }
2970 }
2971
2972 pub fn execute(&mut self, goal: Goal) -> ApiResult<ExecutionResult> {
2976 self.execute_with_options(goal, ExecuteOptions::default())
2977 }
2978
2979 fn expand_add_variant_cascades(&self, mut goal: Goal) -> Goal {
2984 use crate::discover::find_cascade_effects;
2985 use crate::intent::Intent;
2986
2987 let mut cascade_intents = Vec::new();
2988
2989 for intent in &goal.intents {
2990 if let Intent::AddVariant {
2991 target_enum,
2992 variant_name,
2993 variant_type,
2994 ..
2995 } = intent
2996 {
2997 let enum_name_str = target_enum.as_deref().unwrap_or("");
2999 let cascade_result = find_cascade_effects(
3000 &self.context.files,
3001 enum_name_str,
3002 Some(variant_name),
3003 Some(variant_type),
3004 );
3005
3006 for spec in cascade_result.specs {
3008 cascade_intents.push(Intent::from(spec));
3009 }
3010
3011 tracing::debug!(
3012 "AddVariant cascade: {} AddMatchArm intents generated for {}::{}",
3013 cascade_intents.len(),
3014 enum_name_str,
3015 variant_name
3016 );
3017 }
3018 }
3019
3020 if !cascade_intents.is_empty() {
3022 goal.intents.extend(cascade_intents);
3023 }
3024
3025 goal
3026 }
3027
3028 fn expand_remove_variant_cascades(&self, mut goal: Goal) -> Goal {
3034 use crate::discover::find_remove_cascade_effects;
3035 use crate::intent::Intent;
3036
3037 let mut cascade_intents = Vec::new();
3038
3039 for intent in &goal.intents {
3040 if let Intent::RemoveVariant {
3041 target_enum,
3042 variant_name,
3043 ..
3044 } = intent
3045 {
3046 let enum_name_str = target_enum.as_deref().unwrap_or("");
3047 let cascade_result =
3048 find_remove_cascade_effects(&self.context.files, enum_name_str, variant_name);
3049
3050 for spec in cascade_result.specs {
3051 cascade_intents.push(Intent::from(spec));
3052 }
3053
3054 tracing::debug!(
3055 "RemoveVariant cascade: {} RemoveMatchArm intents generated for {}::{}",
3056 cascade_intents.len(),
3057 enum_name_str,
3058 variant_name
3059 );
3060 }
3061 }
3062
3063 if !cascade_intents.is_empty() {
3064 goal.intents.extend(cascade_intents);
3065 }
3066
3067 goal
3068 }
3069
3070 fn validate_workspace_parent_paths(&self, goal: &Goal) -> ApiResult<()> {
3081 let metadata = self.project.metadata();
3082 let workspace_type = metadata.workspace_type();
3083
3084 if workspace_type != WorkspaceType::Workspace {
3086 return Ok(());
3087 }
3088
3089 let workspace_root_str = self.project.workspace_root().to_string_lossy();
3091 let workspace_members: Vec<String> = metadata
3092 .members()
3093 .filter_map(|info| {
3094 info.manifest_path.parent().map(|p| {
3096 p.as_str()
3097 .strip_prefix(workspace_root_str.as_ref())
3098 .unwrap_or(p.as_str())
3099 .trim_start_matches('/')
3100 .to_string()
3101 })
3102 })
3103 .filter(|s| !s.is_empty())
3104 .collect();
3105
3106 if workspace_members.len() <= 1 {
3108 return Ok(());
3109 }
3110
3111 let resolver = self.project.path_resolver();
3113
3114 for intent in &goal.intents {
3116 if let Some(path) = extract_parent_path_string(intent) {
3117 resolver.validate_crate_path(&path, &workspace_members)?;
3118 }
3119 }
3120
3121 Ok(())
3122 }
3123
3124 pub fn execute_with_options(
3143 &mut self,
3144 goal: Goal,
3145 options: ExecuteOptions,
3146 ) -> ApiResult<ExecutionResult> {
3147 if goal.confidence < 0.5 {
3149 return Err(ApiError::InvalidGoal(format!(
3150 "Low confidence goal: {}",
3151 goal.confidence
3152 )));
3153 }
3154
3155 tracing::info!(?goal.intents, ?options, "Executing goal");
3156
3157 self.validate_workspace_parent_paths(&goal)?;
3159
3160 let goal = self.expand_add_variant_cascades(goal);
3162
3163 let goal = self.expand_remove_variant_cascades(goal);
3165
3166 let mut log = TxLog::with_project(self.project.root().to_string_lossy());
3168
3169 let specs = Planner::plan(&goal, Some(self.context.registry()))?;
3172 tracing::debug!("Planned {} mutation specs", specs.len());
3173
3174 if specs.is_empty() {
3175 return Ok(ExecutionResult {
3176 status: ExecutionStatus::no_change("No mutation specs generated from goal"),
3177 files_modified: 0,
3178 total_changes: 0,
3179 session_id: None,
3180 log,
3181 modified_files: vec![],
3182 conflicts: vec![],
3183 syntax_errors: vec![],
3184 success: true,
3185 });
3186 }
3187
3188 let mut blueprint = ParallelBlueprint::from_mutations(specs);
3190 tracing::debug!(
3191 "Blueprint: {} mutations, {} conflicts",
3192 blueprint.mutations.len(),
3193 blueprint.conflicts.len()
3194 );
3195
3196 let detected_conflicts = blueprint.conflicts.clone();
3198 if blueprint.needs_escalation() {
3199 match goal.conflict_strategy {
3200 ConflictStrategy::Fail => {
3201 tracing::warn!(
3202 "Conflicts detected with Fail strategy: {} conflicts",
3203 detected_conflicts.len()
3204 );
3205 return Err(ApiError::Conflicts(detected_conflicts.len()));
3206 }
3207 ConflictStrategy::IntentOrder => {
3208 tracing::info!(
3210 "Resolving {} conflicts by intent order",
3211 detected_conflicts.len()
3212 );
3213 blueprint.conflicts.clear();
3214 }
3215 ConflictStrategy::ParallelOnly => {
3216 tracing::warn!(
3218 "ParallelOnly strategy: {} mutations skipped due to conflicts",
3219 detected_conflicts.len()
3220 );
3221 let status = ExecutionStatus::conflict(format!(
3222 "{} mutations skipped due to conflicts",
3223 detected_conflicts.len()
3224 ))
3225 .with_explanation(
3226 "ParallelOnly strategy was used, which skips conflicting mutations.",
3227 )
3228 .with_suggestion(
3229 "Use ConflictStrategy::IntentOrder to resolve conflicts by execution order",
3230 );
3231 return Ok(ExecutionResult {
3232 status,
3233 files_modified: 0,
3234 total_changes: 0,
3235 session_id: None,
3236 log,
3237 modified_files: vec![],
3238 conflicts: detected_conflicts,
3239 syntax_errors: vec![],
3240 success: false,
3241 });
3242 }
3243 }
3244 }
3245
3246 if options.dry_run {
3248 tracing::info!("Dry run: skipping execution");
3249 let status = ExecutionStatus::ok(format!(
3250 "{} mutations planned (dry run)",
3251 blueprint.mutations.len()
3252 ))
3253 .with_explanation("Dry run mode: changes were planned but not applied.");
3254 return Ok(ExecutionResult {
3255 status,
3256 files_modified: 0,
3257 total_changes: blueprint.mutations.len(),
3258 session_id: None,
3259 log,
3260 modified_files: vec![],
3261 conflicts: detected_conflicts,
3262 syntax_errors: vec![],
3263 success: true,
3264 });
3265 }
3266
3267 let executor = BlueprintExecutor::new();
3272 let result = executor.execute_v2(&blueprint, &mut self.context);
3273
3274 self.spec_cache = None;
3277
3278 if !result.success {
3279 return Err(ApiError::Execution(
3280 result
3281 .error
3282 .unwrap_or_else(|| "Unknown execution error".to_string()),
3283 ));
3284 }
3285
3286 let modified_files = BlueprintExecutor::sync_files_and_rebuild(&result, &mut self.context)?;
3290
3291 tracing::debug!(
3292 "sync_files_and_rebuild returned {} modified files",
3293 modified_files.len()
3294 );
3295 if modified_files.is_empty() && result.total_changes > 0 {
3296 tracing::warn!(
3297 "sync_files_and_rebuild returned 0 files despite {} total changes - possible bug",
3298 result.total_changes
3299 );
3300 }
3301 for (i, file) in modified_files.iter().enumerate() {
3302 tracing::debug!(" Modified file {}: {}", i, file.as_relative().display());
3303 }
3304
3305 let syntax_errors = if options.check_syntax {
3307 let mut errors = Vec::new();
3308 for file_path in &modified_files {
3309 if let Some(file) = self.context.file(file_path) {
3310 let source = match file.to_source() {
3311 Ok(s) => s,
3312 Err(e) => {
3313 errors.push(format!(
3314 "{}: source generation failed: {}",
3315 file_path.as_relative().display(),
3316 e
3317 ));
3318 continue;
3319 }
3320 };
3321 let path_str = file_path.as_relative().display().to_string();
3322 if let Err(e) = syn::parse_file(&source) {
3323 errors.push(format!("{}: {}", path_str, e));
3324 }
3325 }
3326 }
3327 errors
3328 } else {
3329 vec![]
3330 };
3331
3332 for spec_result in &result.results {
3334 if spec_result.success && spec_result.changes > 0 {
3335 let spec = &blueprint.mutations[spec_result.index];
3337 let mutation_data = serde_json::to_value(spec).ok();
3338
3339 let file_path = spec_result
3341 .affected_files
3342 .first()
3343 .map(|wfp| wfp.to_absolute());
3344 log.log(TxAction::MutationApplied {
3345 mutation_type: spec_result.spec_type.clone(),
3346 target: format!("{:?}", spec_result.affected_symbols),
3347 changes: spec_result.changes,
3348 mutation_data,
3349 file_path,
3350 pre_state: None,
3351 post_state: None,
3352 affected_symbols: spec_result.affected_symbols.clone(),
3353 });
3354 }
3355 }
3356
3357 let success = syntax_errors.is_empty();
3361 if success {
3362 self.project
3363 .sync_from_context(&self.context, &modified_files);
3364
3365 if !result.registry_updates.is_empty() {
3368 self.context.commit_changes(&result.registry_updates);
3369 }
3370
3371 for spec_result in &result.results {
3373 for symbol_path in &spec_result.affected_symbols {
3374 if let Some(symbol_id) = self.context.registry().lookup(symbol_path) {
3375 self.suggest.invalidate_for_symbol(&symbol_id);
3376 }
3377 }
3378 }
3379 }
3380
3381 let modified_paths: Vec<PathBuf> =
3383 modified_files.iter().map(|wfp| wfp.to_absolute()).collect();
3384
3385 tracing::info!(
3386 "Executed {} changes across {} files (success={})",
3387 result.total_changes,
3388 modified_paths.len(),
3389 success
3390 );
3391
3392 let status = if !syntax_errors.is_empty() {
3394 ExecutionStatus::syntax_error(format!(
3395 "{} syntax errors in {} files",
3396 syntax_errors.len(),
3397 modified_paths.len()
3398 ))
3399 .with_explanation("Mutations were applied but resulted in invalid Rust syntax.")
3400 .with_suggestions(syntax_errors.iter().map(|e| format!("Fix: {}", e)))
3401 } else if result.total_changes == 0 {
3402 ExecutionStatus::no_change("No changes were made")
3403 .with_explanation("The goal was processed but no mutations were applied.")
3404 } else if !detected_conflicts.is_empty() {
3405 ExecutionStatus::resolved(format!(
3406 "{} changes ({} conflicts auto-resolved)",
3407 result.total_changes,
3408 detected_conflicts.len()
3409 ))
3410 .with_explanation("Conflicts were automatically resolved by execution order.")
3411 } else {
3412 ExecutionStatus::ok(format!(
3413 "{} changes in {} files",
3414 result.total_changes,
3415 modified_paths.len()
3416 ))
3417 };
3418
3419 Ok(ExecutionResult {
3420 status,
3421 files_modified: modified_paths.len(),
3422 total_changes: result.total_changes,
3423 session_id: None,
3424 log,
3425 modified_files: modified_paths,
3426 conflicts: detected_conflicts,
3427 syntax_errors,
3428 success,
3429 })
3430 }
3431
3432 pub fn execute_and_save(&mut self, goal: Goal) -> ApiResult<ExecutionResult> {
3434 self.execute_and_save_with_options(goal, ExecuteOptions::default())
3435 }
3436
3437 pub fn execute_and_save_with_options(
3439 &mut self,
3440 goal: Goal,
3441 options: ExecuteOptions,
3442 ) -> ApiResult<ExecutionResult> {
3443 let mut result = self.execute_with_options(goal, options)?;
3444
3445 let session_id = self.storage.save(&result.log)?;
3447 result.session_id = Some(session_id);
3448
3449 Ok(result)
3450 }
3451
3452 pub fn list_sessions(&self) -> ApiResult<Vec<String>> {
3454 Ok(self.storage.list_sessions()?)
3455 }
3456
3457 pub fn load_session(&self, session_id: &str) -> ApiResult<ryo_storage::TxLog> {
3459 Ok(self.storage.load(session_id)?)
3460 }
3461
3462 pub fn storage_mut(&mut self) -> &mut dyn Storage {
3464 self.storage.as_mut()
3465 }
3466
3467 pub fn context(&self) -> &AnalysisContext {
3469 &self.context
3470 }
3471
3472 pub fn project(&self) -> &Project {
3474 &self.project
3475 }
3476
3477 pub fn save_uuid_mappings(&self) -> ApiResult<()> {
3489 let mappings = self.context.registry.export_uuid_mapping_strings();
3490 self.uuid_storage
3491 .save(&mappings)
3492 .map_err(|e| ApiError::Execution(format!("Failed to save UUID mappings: {}", e)))
3493 }
3494
3495 pub fn suggest_choices(
3511 &self,
3512 request: SuggestChoicesRequest,
3513 ) -> ApiResult<SuggestChoicesResponse> {
3514 let suggest_id: SuggestId = request
3516 .id
3517 .parse()
3518 .map_err(|e| ApiError::InvalidGoal(format!("Invalid ID format: {}", e)))?;
3519
3520 if !self.suggest.is_valid(suggest_id) {
3522 return Ok(SuggestChoicesResponse {
3523 suggestion_id: request.id,
3524 error: Some("Suggestion not found or expired".to_string()),
3525 ..Default::default()
3526 });
3527 }
3528
3529 let pattern_name = self
3531 .suggest
3532 .pattern_name(suggest_id)
3533 .unwrap_or("Unknown")
3534 .to_string();
3535
3536 let stored = self.suggest.get(suggest_id);
3538 if stored.is_none() {
3539 return Ok(SuggestChoicesResponse {
3540 suggestion_id: request.id,
3541 pattern_name,
3542 error: Some("Suggestion data not available".to_string()),
3543 ..Default::default()
3544 });
3545 }
3546
3547 Ok(SuggestChoicesResponse {
3551 suggestion_id: request.id,
3552 pattern_name,
3553 choices: Vec::new(),
3554 has_choices: false,
3555 error: None,
3556 })
3557 }
3558
3559 pub fn suggest_verify(
3579 &self,
3580 request: SuggestVerifyRequest,
3581 ) -> ApiResult<SuggestVerifyResponse> {
3582 let start = Instant::now();
3583
3584 let suggest_id: SuggestId = request
3586 .id
3587 .parse()
3588 .map_err(|e| ApiError::InvalidGoal(format!("Invalid ID format: {}", e)))?;
3589
3590 if !self.suggest.is_valid(suggest_id) {
3592 return Ok(SuggestVerifyResponse {
3593 suggestion_id: request.id,
3594 choice_id: request.choice_id,
3595 passed: false,
3596 level: format!("{:?}", request.level),
3597 duration_ms: start.elapsed().as_millis() as u64,
3598 diagnostics: vec!["Suggestion not found or expired".to_string()],
3599 error: Some("Suggestion not found or expired".to_string()),
3600 });
3601 }
3602
3603 let specs = match self.suggest.to_mutation_specs(suggest_id, &self.context) {
3605 Some(specs) if !specs.is_empty() => specs,
3606 _ => {
3607 return Ok(SuggestVerifyResponse {
3608 suggestion_id: request.id,
3609 choice_id: request.choice_id,
3610 passed: false,
3611 level: format!("{:?}", request.level),
3612 duration_ms: start.elapsed().as_millis() as u64,
3613 diagnostics: vec!["No mutations could be generated".to_string()],
3614 error: Some("No mutations could be generated".to_string()),
3615 });
3616 }
3617 };
3618
3619 let (passed, diagnostics) = match request.level {
3621 VerifyLevel::Light => {
3622 self.verify_with_graph_check(&specs)
3624 }
3625 VerifyLevel::Full => {
3626 self.verify_with_cargo_check(&specs)
3628 }
3629 };
3630
3631 Ok(SuggestVerifyResponse {
3632 suggestion_id: request.id,
3633 choice_id: request.choice_id,
3634 passed,
3635 level: format!("{:?}", request.level),
3636 duration_ms: start.elapsed().as_millis() as u64,
3637 diagnostics,
3638 error: None,
3639 })
3640 }
3641
3642 fn verify_with_cargo_check(&self, specs: &[ryo_executor::MutationSpec]) -> (bool, Vec<String>) {
3650 use ryo_symbol::WorkspacePathResolver;
3651 use std::collections::HashMap;
3652
3653 let mut forked_ctx = self.context.fork_clone();
3655 let original_files = self.context.files.clone();
3656
3657 let blueprint = ParallelBlueprint::from_mutations(specs.to_vec());
3659 let executor = BlueprintExecutor::default();
3660 let exec_result = executor.execute_v2(&blueprint, &mut forked_ctx);
3661
3662 if !exec_result.success {
3663 return (
3664 false,
3665 vec![format!(
3666 "Mutation execution failed: {}",
3667 exec_result
3668 .error
3669 .unwrap_or_else(|| "Unknown error".to_string())
3670 )],
3671 );
3672 }
3673
3674 if exec_result.total_changes == 0 {
3676 return (
3677 true,
3678 vec!["No file changes detected (no-op mutation)".to_string()],
3679 );
3680 }
3681
3682 let original_sources: HashMap<_, _> = HashMap::new();
3684 if let Err(e) = BlueprintExecutor::sync_files_and_rebuild(&exec_result, &mut forked_ctx) {
3685 return (false, vec![format!("File sync failed: {}", e)]);
3686 }
3687 let changes = match FileChange::from_execution_diff(
3688 &original_files,
3689 &forked_ctx.files,
3690 &original_sources,
3691 ) {
3692 Ok(c) => c,
3693 Err(e) => return (false, vec![format!("Source generation failed: {}", e)]),
3694 };
3695
3696 let resolver = WorkspacePathResolver::new(self.context.workspace_root().to_path_buf());
3698 let input = VerificationInput::new(changes.clone(), resolver);
3699
3700 let pipeline = VerificationPipeline::new().add_filesystem(CargoVerifier::new());
3702
3703 match pipeline.run_postcheck(&input) {
3704 Ok(result) => {
3705 let passed = result.pipeline_result.is_success();
3706 let mut diagnostics = vec![format!(
3707 "{} file(s) changed, cargo check {}",
3708 changes.len(),
3709 if passed { "passed" } else { "failed" }
3710 )];
3711
3712 for diag in result.pipeline_result.all_diagnostics() {
3714 diagnostics.push(format!(" {:?}: {}", diag.level, diag.message));
3715 }
3716
3717 (passed, diagnostics)
3718 }
3719 Err(e) => (false, vec![format!("Verification failed: {}", e)]),
3720 }
3721 }
3722
3723 fn verify_with_graph_check(&self, specs: &[ryo_executor::MutationSpec]) -> (bool, Vec<String>) {
3731 use ryo_symbol::WorkspacePathResolver;
3732 use std::collections::HashMap;
3733
3734 let mut forked_ctx = self.context.fork_clone();
3736 let original_files = self.context.files.clone();
3737
3738 let blueprint = ParallelBlueprint::from_mutations(specs.to_vec());
3740 let executor = BlueprintExecutor::default();
3741 let exec_result = executor.execute_v2(&blueprint, &mut forked_ctx);
3742
3743 if !exec_result.success {
3744 return (
3745 false,
3746 vec![format!(
3747 "Mutation execution failed: {}",
3748 exec_result
3749 .error
3750 .unwrap_or_else(|| "Unknown error".to_string())
3751 )],
3752 );
3753 }
3754
3755 if exec_result.total_changes == 0 {
3757 return (
3758 true,
3759 vec!["No file changes detected (no-op mutation)".to_string()],
3760 );
3761 }
3762
3763 let original_sources: HashMap<_, _> = HashMap::new();
3765 if let Err(e) = BlueprintExecutor::sync_files_and_rebuild(&exec_result, &mut forked_ctx) {
3766 return (false, vec![format!("File sync failed: {}", e)]);
3767 }
3768 let changes = match FileChange::from_execution_diff(
3769 &original_files,
3770 &forked_ctx.files,
3771 &original_sources,
3772 ) {
3773 Ok(c) => c,
3774 Err(e) => return (false, vec![format!("Source generation failed: {}", e)]),
3775 };
3776
3777 let resolver = WorkspacePathResolver::new(self.context.workspace_root().to_path_buf());
3779 let input = VerificationInput::new(changes.clone(), resolver);
3780
3781 let verifier = GraphVerifier::new();
3783 let result = verifier.verify_in_memory(&input, &forked_ctx);
3784
3785 let passed = result.is_success();
3786 let mut diagnostics = vec![format!(
3787 "{} file(s) changed, graph check {} ({:.2}ms)",
3788 changes.len(),
3789 if passed { "passed" } else { "failed" },
3790 result.duration.as_secs_f64() * 1000.0
3791 )];
3792
3793 for diag in &result.diagnostics {
3795 diagnostics.push(format!(" {:?}: {}", diag.level, diag.message));
3796 }
3797
3798 (passed, diagnostics)
3799 }
3800
3801 pub fn suggest_compare(
3816 &self,
3817 request: SuggestCompareRequest,
3818 ) -> ApiResult<SuggestCompareResponse> {
3819 let suggest_id: SuggestId = request
3821 .id
3822 .parse()
3823 .map_err(|e| ApiError::InvalidGoal(format!("Invalid ID format: {}", e)))?;
3824
3825 if !self.suggest.is_valid(suggest_id) {
3827 return Ok(SuggestCompareResponse {
3828 suggestion_id: request.id,
3829 error: Some("Suggestion not found or expired".to_string()),
3830 ..Default::default()
3831 });
3832 }
3833
3834 let pattern_name = self
3836 .suggest
3837 .pattern_name(suggest_id)
3838 .unwrap_or("Unknown")
3839 .to_string();
3840
3841 let comparison_table = format!(
3844 "Suggestion: {}\nPattern: {}\n\nNo design choices available for comparison.\n",
3845 request.id, pattern_name
3846 );
3847
3848 Ok(SuggestCompareResponse {
3849 suggestion_id: request.id,
3850 comparison_table,
3851 ranked_choices: Vec::new(),
3852 recommendation_reason: None,
3853 error: None,
3854 })
3855 }
3856
3857 pub fn suggest_generate(
3876 &self,
3877 request: SuggestGenerateRequest,
3878 ) -> ApiResult<SuggestGenerateResponse> {
3879 use crate::api::types::{ParamInfo, PatternInfo};
3880
3881 if request.list {
3883 let mut patterns: Vec<PatternInfo> = self
3885 .suggest
3886 .list_parameterized()
3887 .into_iter()
3888 .map(|info| PatternInfo {
3889 name: info.name.to_string(),
3890 description: info.description,
3891 category: format!("{:?}", info.category),
3892 params: info
3893 .param_schema
3894 .into_iter()
3895 .map(|p| ParamInfo {
3896 name: p.name,
3897 description: p.description,
3898 required: p.required,
3899 })
3900 .collect(),
3901 })
3902 .collect();
3903
3904 for entry in self.generator_store.all_generators() {
3906 patterns.push(PatternInfo {
3907 name: entry.template.name().to_string(),
3908 description: entry.template.description().to_string(),
3909 category: entry.template.category().unwrap_or("generator").to_string(),
3910 params: entry
3911 .template
3912 .params
3913 .iter()
3914 .map(|p| ParamInfo {
3915 name: p.name.clone(),
3916 description: p.description.clone(),
3917 required: p.required,
3918 })
3919 .collect(),
3920 });
3921 }
3922
3923 return Ok(SuggestGenerateResponse {
3924 patterns,
3925 ..Default::default()
3926 });
3927 }
3928
3929 let pattern = match &request.pattern {
3931 Some(p) => p,
3932 None => {
3933 return Ok(SuggestGenerateResponse {
3934 error: Some(
3935 "Pattern name required. Use --list to see available patterns.".to_string(),
3936 ),
3937 ..Default::default()
3938 });
3939 }
3940 };
3941
3942 if let Some(opps) =
3944 self.suggest
3945 .generate_with_params(&self.context, pattern, &request.params)
3946 {
3947 if !opps.is_empty() {
3948 return self.generate_from_suggest_service(pattern, opps, &request);
3949 }
3950 }
3951
3952 if let Some(entry) = self.generator_store.find_by_name(pattern) {
3954 return self.generate_from_template(entry, &request);
3955 }
3956
3957 if let Some(entry) = self.generator_store.find_by_id(pattern) {
3959 return self.generate_from_template(entry, &request);
3960 }
3961
3962 Ok(SuggestGenerateResponse {
3963 error: Some(format!(
3964 "Pattern '{}' not found. Use --list to see available patterns.",
3965 pattern
3966 )),
3967 ..Default::default()
3968 })
3969 }
3970
3971 fn generate_from_suggest_service(
3973 &self,
3974 pattern: &str,
3975 opportunities: Vec<ryo_suggest::SuggestOpportunity>,
3976 request: &SuggestGenerateRequest,
3977 ) -> ApiResult<SuggestGenerateResponse> {
3978 let suggestions: Vec<Suggestion> = opportunities
3980 .iter()
3981 .enumerate()
3982 .map(|(i, opp)| Suggestion {
3983 id: format!("GEN{:03}", i),
3984 rule_id: None,
3985 title: format!("Generate {}", pattern),
3986 description: opp.message.clone(),
3987 category: "Generation".to_string(),
3988 impact: "medium".to_string(),
3989 file: std::path::PathBuf::from(&opp.location.file),
3990 symbol_path: Some(opp.location.symbol_path.to_string()),
3991 fix_intent: None,
3992 })
3993 .collect();
3994
3995 let preview = if !request.apply {
3997 let mut preview_text = String::new();
3998 for opp in &opportunities {
3999 if let Some((_, suggest)) = self.suggest.registry().get_by_name(pattern) {
4000 if let Ok(specs) = suggest.to_mutation_specs(&self.context, opp) {
4001 for spec in specs {
4002 preview_text.push_str(&format!("{:?}\n", spec));
4003 }
4004 }
4005 }
4006 }
4007 Some(preview_text)
4008 } else {
4009 None
4010 };
4011
4012 let (applied, files_modified) = if request.apply && !request.dry_run {
4014 (false, 0)
4016 } else {
4017 (false, 0)
4018 };
4019
4020 Ok(SuggestGenerateResponse {
4021 suggestions,
4022 preview,
4023 applied,
4024 files_modified,
4025 ..Default::default()
4026 })
4027 }
4028
4029 fn generate_from_template(
4031 &self,
4032 entry: &ryo_suggest::GeneratorEntry,
4033 request: &SuggestGenerateRequest,
4034 ) -> ApiResult<SuggestGenerateResponse> {
4035 let template = &entry.template;
4036
4037 let rendered = match template.render(&request.params) {
4039 Ok(code) => code,
4040 Err(e) => {
4041 return Ok(SuggestGenerateResponse {
4042 error: Some(format!("Template render error: {}", e)),
4043 ..Default::default()
4044 });
4045 }
4046 };
4047
4048 let target_file = template.render_target_file(&request.params);
4049
4050 let suggestions = vec![Suggestion {
4052 id: format!("GEN-{}", template.id()),
4053 rule_id: Some(template.id().to_string()),
4054 title: format!("Generate {}", template.name()),
4055 description: template.description().to_string(),
4056 category: template.category().unwrap_or("generator").to_string(),
4057 impact: "medium".to_string(),
4058 file: std::path::PathBuf::from(&target_file),
4059 symbol_path: None, fix_intent: None,
4061 }];
4062
4063 let preview = if !request.apply {
4065 Some(format!(
4066 "// Target: {}\n// Position: {:?}\n\n{}",
4067 target_file, template.template.position, rendered
4068 ))
4069 } else {
4070 None
4071 };
4072
4073 let (applied, files_modified) = if request.apply && !request.dry_run {
4075 (false, 0)
4078 } else {
4079 (false, 0)
4080 };
4081
4082 Ok(SuggestGenerateResponse {
4083 suggestions,
4084 preview,
4085 applied,
4086 files_modified,
4087 ..Default::default()
4088 })
4089 }
4090}
4091
4092fn matches_lock_pattern_server(acq: &ryo_analysis::LockAcquisitionV2, pattern: &str) -> bool {
4096 if pattern.is_empty() || pattern == "*" {
4097 return true;
4098 }
4099
4100 let pattern_lower = pattern.to_lowercase();
4101
4102 let type_name = format!("{:?}", acq.lock_type).to_lowercase();
4104 if type_name == pattern_lower || type_name.contains(&pattern_lower) {
4105 return true;
4106 }
4107
4108 let name_lower = acq.lock_name.to_lowercase();
4109
4110 if pattern_lower.starts_with('*') && pattern_lower.ends_with('*') && pattern_lower.len() > 1 {
4112 let middle = &pattern_lower[1..pattern_lower.len() - 1];
4113 if middle.is_empty() {
4114 return true;
4115 }
4116 name_lower.contains(middle)
4117 } else if let Some(stripped) = pattern_lower.strip_prefix('*') {
4118 name_lower.ends_with(stripped)
4119 } else if pattern_lower.ends_with('*') {
4120 name_lower.starts_with(&pattern_lower[..pattern_lower.len() - 1])
4121 } else {
4122 name_lower.contains(&pattern_lower)
4123 }
4124}
4125
4126#[derive(Debug, Clone)]
4132pub enum HookResult {
4133 Success,
4135 Warning(String),
4137 Error(String),
4139}
4140
4141impl HookResult {
4142 pub fn is_success(&self) -> bool {
4144 matches!(self, HookResult::Success)
4145 }
4146
4147 pub fn is_error(&self) -> bool {
4149 matches!(self, HookResult::Error(_))
4150 }
4151}
4152
4153pub trait PostExecutionHook: Send + Sync {
4178 fn name(&self) -> &str;
4180
4181 fn run(&self, result: &ExecutionResult, project_path: &Path) -> HookResult;
4190
4191 fn run_on_dry_run(&self) -> bool {
4194 false
4195 }
4196
4197 fn run_on_syntax_error(&self) -> bool {
4200 false
4201 }
4202}
4203
4204#[cfg(test)]
4209mod tests {
4210 use super::*;
4211 use crate::intent::{ConflictStrategy, Goal, IdentKind, Intent, ScopeHint, StmtInsertPosition};
4212 use std::fs;
4213 use tempfile::tempdir;
4214
4215 fn create_test_project() -> (tempfile::TempDir, Project) {
4217 let dir = tempdir().unwrap();
4218 let src = dir.path().join("src");
4219 fs::create_dir(&src).unwrap();
4220
4221 fs::write(
4223 dir.path().join("Cargo.toml"),
4224 r#"[package]
4225name = "test-project"
4226version = "0.1.0"
4227edition = "2021"
4228"#,
4229 )
4230 .unwrap();
4231
4232 fs::write(
4233 src.join("lib.rs"),
4234 r#"
4235pub fn old_name() -> i32 {
4236 42
4237}
4238
4239pub struct OldStruct {
4240 pub value: i32,
4241}
4242"#,
4243 )
4244 .unwrap();
4245
4246 let project = Project::load(dir.path()).unwrap();
4247 (dir, project)
4248 }
4249
4250 #[test]
4255 fn test_api_new() {
4256 let (dir, _project) = create_test_project();
4257 let _api = Api::from_path(dir.path()).unwrap();
4258 }
4260
4261 #[test]
4262 fn test_api_from_path() {
4263 let (dir, _project) = create_test_project();
4264 let api = Api::from_path(dir.path()).unwrap();
4265 assert!(!api.context.registry().is_empty());
4266 }
4267
4268 #[test]
4269 fn test_storage_mut() {
4270 let (dir, _project) = create_test_project();
4271 let mut api = Api::from_path(dir.path()).unwrap();
4272 let _storage = api.storage_mut();
4273 }
4275
4276 #[test]
4281 fn test_execute_options_default() {
4282 let opts = ExecuteOptions::default();
4283 assert!(!opts.dry_run);
4284 assert!(!opts.check_syntax);
4285 }
4286
4287 #[test]
4288 fn test_execute_options_new() {
4289 let opts = ExecuteOptions::new();
4290 assert!(!opts.dry_run);
4291 assert!(!opts.check_syntax);
4292 }
4293
4294 #[test]
4295 fn test_execute_options_dry_run() {
4296 let opts = ExecuteOptions::new().dry_run();
4297 assert!(opts.dry_run);
4298 assert!(!opts.check_syntax);
4299 }
4300
4301 #[test]
4302 fn test_execute_options_check_syntax() {
4303 let opts = ExecuteOptions::new().check_syntax();
4304 assert!(!opts.dry_run);
4305 assert!(opts.check_syntax);
4306 }
4307
4308 #[test]
4309 fn test_execute_options_chained() {
4310 let opts = ExecuteOptions::new().dry_run().check_syntax();
4311 assert!(opts.dry_run);
4312 assert!(opts.check_syntax);
4313 }
4314
4315 #[test]
4320 fn test_discover_all() {
4321 let (dir, _project) = create_test_project();
4322 let mut api = Api::from_path(dir.path()).unwrap();
4323
4324 let response = api
4325 .discover(DiscoverRequest {
4326 pattern: "*".to_string(),
4327 ..Default::default()
4328 })
4329 .unwrap();
4330
4331 assert!(response.total > 0);
4332 }
4333
4334 #[test]
4335 fn test_discover_with_pattern() {
4336 let (dir, _project) = create_test_project();
4337 let mut api = Api::from_path(dir.path()).unwrap();
4338
4339 let response = api
4340 .discover(DiscoverRequest {
4341 pattern: "Old*".to_string(),
4342 ..Default::default()
4343 })
4344 .unwrap();
4345
4346 assert!(response.symbols.iter().any(|s| s.name.starts_with("Old")));
4348 }
4349
4350 #[test]
4355 fn test_status() {
4356 let (dir, _project) = create_test_project();
4357 let api = Api::from_path(dir.path()).unwrap();
4358
4359 let status = api.status();
4360 assert!(status.files > 0);
4361 assert!(status.symbols > 0);
4362 }
4363
4364 #[test]
4369 fn test_execute_low_confidence_goal() {
4370 let (dir, _project) = create_test_project();
4371 let mut api = Api::from_path(dir.path()).unwrap();
4372
4373 let goal = Goal::new(
4374 "rename old_name to new_name",
4375 Intent::RenameIdent {
4376 symbol_id: None,
4377 symbol_path: None,
4378 target_ident: Some("old_name".to_string()),
4379 to: "new_name".to_string(),
4380 kind: IdentKind::Any,
4381 },
4382 )
4383 .with_confidence(0.3); let result = api.execute(goal);
4386 assert!(result.is_err());
4387 assert!(matches!(result.unwrap_err(), ApiError::InvalidGoal(_)));
4388 }
4389
4390 #[test]
4395 fn test_execute_empty_intents() {
4396 let (dir, _project) = create_test_project();
4397 let mut api = Api::from_path(dir.path()).unwrap();
4398
4399 let goal = Goal {
4401 query: String::new(),
4402 intents: vec![],
4403 scope: ScopeHint::default(),
4404 constraints: vec![],
4405 confidence: 1.0,
4406 conflict_strategy: ConflictStrategy::Fail,
4407 };
4408
4409 let result = api.execute(goal).unwrap();
4410 assert!(result.success);
4411 assert_eq!(result.files_modified, 0);
4412 assert_eq!(result.total_changes, 0);
4413 }
4414
4415 #[test]
4420 fn test_execute_dry_run() {
4421 let (dir, _project) = create_test_project();
4422 let mut api = Api::from_path(dir.path()).unwrap();
4423
4424 let goal = Goal::new(
4425 "rename old_name to new_name",
4426 Intent::RenameIdent {
4427 symbol_id: None,
4428 symbol_path: None,
4429 target_ident: Some("old_name".to_string()),
4430 to: "new_name".to_string(),
4431 kind: IdentKind::Any,
4432 },
4433 );
4434
4435 let opts = ExecuteOptions::new().dry_run();
4436 let result = api.execute_with_options(goal, opts).unwrap();
4437
4438 assert!(result.success);
4439 assert_eq!(result.files_modified, 0);
4441 }
4442
4443 #[test]
4448 fn test_run_api() {
4449 let (dir, _project) = create_test_project();
4450 let mut api = Api::from_path(dir.path()).unwrap();
4451
4452 let goal = Goal::new(
4453 "rename old_name to new_name",
4454 Intent::RenameIdent {
4455 symbol_id: None,
4456 symbol_path: None,
4457 target_ident: Some("old_name".to_string()),
4458 to: "new_name".to_string(),
4459 kind: IdentKind::Any,
4460 },
4461 );
4462
4463 let response = api
4464 .run(RunRequest {
4465 goal,
4466 dry_run: true,
4467 check_syntax: false,
4468 })
4469 .unwrap();
4470
4471 assert!(response.success);
4472 }
4473
4474 #[test]
4479 fn test_list_sessions_empty() {
4480 let (dir, _project) = create_test_project();
4481 let api = Api::from_path(dir.path()).unwrap();
4482
4483 let sessions = api.list_sessions().unwrap();
4484 assert!(sessions.is_empty());
4485 }
4486
4487 #[test]
4488 fn test_execute_and_save() {
4489 let (dir, _project) = create_test_project();
4490 let mut api = Api::from_path(dir.path()).unwrap();
4491
4492 let goal = Goal {
4494 query: String::new(),
4495 intents: vec![],
4496 scope: ScopeHint::default(),
4497 constraints: vec![],
4498 confidence: 1.0,
4499 conflict_strategy: ConflictStrategy::Fail,
4500 };
4501
4502 let result = api.execute_and_save(goal).unwrap();
4503 assert!(result.session_id.is_some());
4504
4505 let sessions = api.list_sessions().unwrap();
4506 assert_eq!(sessions.len(), 1);
4507 }
4508
4509 #[test]
4510 fn test_load_session() {
4511 let (dir, _project) = create_test_project();
4512 let mut api = Api::from_path(dir.path()).unwrap();
4513
4514 let goal = Goal {
4515 query: String::new(),
4516 intents: vec![],
4517 scope: ScopeHint::default(),
4518 constraints: vec![],
4519 confidence: 1.0,
4520 conflict_strategy: ConflictStrategy::Fail,
4521 };
4522
4523 let result = api.execute_and_save(goal).unwrap();
4524 let session_id = result.session_id.unwrap();
4525
4526 let log = api.load_session(&session_id).unwrap();
4527 let _ = log.entries().len(); }
4529
4530 #[test]
4535 fn test_hook_result_success() {
4536 let result = HookResult::Success;
4537 assert!(result.is_success());
4538 assert!(!result.is_error());
4539 }
4540
4541 #[test]
4542 fn test_hook_result_warning() {
4543 let result = HookResult::Warning("test warning".to_string());
4544 assert!(!result.is_success());
4545 assert!(!result.is_error());
4546 }
4547
4548 #[test]
4549 fn test_hook_result_error() {
4550 let result = HookResult::Error("test error".to_string());
4551 assert!(!result.is_success());
4552 assert!(result.is_error());
4553 }
4554
4555 #[test]
4560 fn test_api_error_display() {
4561 let err = ApiError::InvalidGoal("test".to_string());
4562 assert!(err.to_string().contains("Invalid goal"));
4563
4564 let err = ApiError::Conflicts(3);
4565 assert!(err.to_string().contains("3 conflicts"));
4566
4567 let err = ApiError::SyntaxError("parse failed".to_string());
4568 assert!(err.to_string().contains("Syntax error"));
4569 }
4570
4571 #[test]
4576 fn test_execution_result_fields() {
4577 let result = ExecutionResult {
4578 status: ExecutionStatus::ok("5 changes in 2 files"),
4579 files_modified: 2,
4580 total_changes: 5,
4581 session_id: Some("test-session".to_string()),
4582 log: TxLog::new(),
4583 modified_files: vec![PathBuf::from("file1.rs"), PathBuf::from("file2.rs")],
4584 conflicts: vec![],
4585 syntax_errors: vec![],
4586 success: true,
4587 };
4588
4589 assert_eq!(result.files_modified, 2);
4590 assert_eq!(result.total_changes, 5);
4591 assert!(result.success);
4592 assert!(result.status.is_success());
4593 assert_eq!(result.modified_files.len(), 2);
4594 }
4595
4596 #[test]
4597 fn test_execution_status_codes() {
4598 assert!(StatusCode::Ok.is_success());
4600 assert!(StatusCode::NoChange.is_success());
4601 assert!(StatusCode::Resolved.is_success());
4602
4603 assert!(StatusCode::Invalid.is_error());
4605 assert!(StatusCode::NotFound.is_error());
4606 assert!(StatusCode::Conflict.is_error());
4607 assert!(StatusCode::SyntaxError.is_error());
4608
4609 assert_eq!(StatusCode::Ok.as_http_code(), 200);
4611 assert_eq!(StatusCode::NoChange.as_http_code(), 204);
4612 assert_eq!(StatusCode::Conflict.as_http_code(), 409);
4613 }
4614
4615 #[test]
4616 fn test_execution_status_builder() {
4617 let status = ExecutionStatus::ok("3 changes")
4618 .with_explanation("Renamed foo to bar")
4619 .with_suggestion("Run tests to verify");
4620
4621 assert!(status.is_success());
4622 assert_eq!(status.detail.reason, "3 changes");
4623 assert!(status.detail.explanation.is_some());
4624 assert_eq!(status.detail.suggestions.len(), 1);
4625 }
4626
4627 fn create_multi_function_project() -> (tempfile::TempDir, Project) {
4637 let dir = tempdir().unwrap();
4638 let src = dir.path().join("src");
4639 fs::create_dir(&src).unwrap();
4640
4641 fs::write(
4642 src.join("lib.rs"),
4643 r#"
4644pub fn alpha() -> i32 {
4645 1
4646}
4647
4648pub fn beta() -> i32 {
4649 alpha() + 1
4650}
4651
4652pub fn gamma() -> i32 {
4653 beta() + alpha()
4654}
4655"#,
4656 )
4657 .unwrap();
4658
4659 fs::write(
4661 dir.path().join("Cargo.toml"),
4662 r#"[package]
4663name = "test-project"
4664version = "0.1.0"
4665edition = "2021"
4666"#,
4667 )
4668 .unwrap();
4669
4670 let project = Project::load(dir.path()).unwrap();
4671 (dir, project)
4672 }
4673
4674 #[test]
4675 fn test_discover_then_run_preserves_symbol_id() {
4676 let (dir, _project) = create_multi_function_project();
4679 let mut api = Api::from_path(dir.path()).unwrap();
4680
4681 let discover_response = api
4683 .discover(DiscoverRequest {
4684 pattern: "alpha".to_string(),
4685 ..Default::default()
4686 })
4687 .unwrap();
4688
4689 assert!(discover_response.total > 0, "Should find 'alpha' function");
4690
4691 let alpha_symbol = discover_response
4693 .symbols
4694 .iter()
4695 .find(|s| s.name == "alpha")
4696 .expect("Should find 'alpha' in discover response");
4697 assert_eq!(alpha_symbol.name, "alpha");
4698
4699 let goal = Goal::new(
4701 "rename alpha to alpha_renamed",
4702 Intent::RenameIdent {
4703 symbol_id: None,
4704 symbol_path: None,
4705 target_ident: Some("alpha".to_string()),
4706 to: "alpha_renamed".to_string(),
4707 kind: IdentKind::Fn,
4708 },
4709 );
4710
4711 let result = api.execute(goal).unwrap();
4712 assert!(result.success, "Rename should succeed");
4713 assert!(result.total_changes > 0, "Should have made changes");
4714
4715 let files: Vec<_> = api.context().files().keys().cloned().collect();
4718 assert!(!files.is_empty(), "Should still have files in context");
4719
4720 for file_path in &files {
4722 assert!(
4723 api.context().file(file_path).is_some(),
4724 "File {:?} should be accessible after run",
4725 file_path
4726 );
4727 }
4728
4729 let discover_after = api
4731 .discover(DiscoverRequest {
4732 pattern: "alpha_renamed".to_string(),
4733 ..Default::default()
4734 })
4735 .unwrap();
4736
4737 let _ = discover_after; assert!(
4746 !api.context().registry().is_empty(),
4747 "FileRegistry should have entries after run"
4748 );
4749 }
4750
4751 #[test]
4752 fn test_run_then_discover_context_survives() {
4753 let (dir, _project) = create_multi_function_project();
4755 let mut api = Api::from_path(dir.path()).unwrap();
4756
4757 let goal = Goal::new(
4759 "rename beta to beta_new",
4760 Intent::RenameIdent {
4761 symbol_id: None,
4762 symbol_path: None,
4763 target_ident: Some("beta".to_string()),
4764 to: "beta_new".to_string(),
4765 kind: IdentKind::Fn,
4766 },
4767 );
4768
4769 let result = api.execute(goal).unwrap();
4770 assert!(result.success, "First rename should succeed");
4771
4772 let discover_response = api
4774 .discover(DiscoverRequest {
4775 pattern: "gamma".to_string(),
4776 ..Default::default()
4777 })
4778 .unwrap();
4779
4780 assert!(
4782 discover_response.symbols.iter().any(|s| s.name == "gamma"),
4783 "gamma should still be discoverable after run"
4784 );
4785
4786 let goal2 = Goal::new(
4788 "rename gamma to gamma_new",
4789 Intent::RenameIdent {
4790 symbol_id: None,
4791 symbol_path: None,
4792 target_ident: Some("gamma".to_string()),
4793 to: "gamma_new".to_string(),
4794 kind: IdentKind::Fn,
4795 },
4796 );
4797
4798 let result2 = api.execute(goal2).unwrap();
4799 assert!(result2.success, "Second rename should succeed");
4800 }
4801
4802 #[test]
4803 fn test_multiple_sequential_runs_preserve_context() {
4804 let (dir, _project) = create_multi_function_project();
4806 let mut api = Api::from_path(dir.path()).unwrap();
4807
4808 let initial_file_count = api.context().files().len();
4810
4811 let renames = [
4813 ("alpha", "alpha_v2"),
4814 ("beta", "beta_v2"),
4815 ("gamma", "gamma_v2"),
4816 ];
4817
4818 for (from, to) in renames {
4819 let goal = Goal::new(
4820 format!("rename {} to {}", from, to),
4821 Intent::RenameIdent {
4822 symbol_id: None,
4823 symbol_path: None,
4824 target_ident: Some(from.to_string()),
4825 to: to.to_string(),
4826 kind: IdentKind::Fn,
4827 },
4828 );
4829
4830 let result = api.execute(goal).unwrap();
4831 assert!(result.success, "Rename {} -> {} should succeed", from, to);
4832 }
4833
4834 assert_eq!(
4836 api.context().files().len(),
4837 initial_file_count,
4838 "File count should remain stable after multiple runs"
4839 );
4840
4841 for (file_path, _) in api.context().files().iter() {
4843 assert!(
4844 api.context().file(file_path).is_some(),
4845 "File {:?} should be accessible after multiple runs",
4846 file_path
4847 );
4848 }
4849 }
4850
4851 #[test]
4852 fn test_spec_cache_invalidated_after_run() {
4853 let (dir, _project) = create_multi_function_project();
4855 let mut api = Api::from_path(dir.path()).unwrap();
4856
4857 let spec_request = SpecRequest::show();
4859 let spec_result1 = api.spec(spec_request.clone());
4860 assert!(spec_result1.is_ok(), "First spec() call should succeed");
4861
4862 let goal = Goal::new(
4864 "rename alpha to alpha_changed",
4865 Intent::RenameIdent {
4866 symbol_id: None,
4867 symbol_path: None,
4868 target_ident: Some("alpha".to_string()),
4869 to: "alpha_changed".to_string(),
4870 kind: IdentKind::Fn,
4871 },
4872 );
4873
4874 let result = api.execute(goal).unwrap();
4875 assert!(result.success, "Rename should succeed");
4876
4877 let spec_result2 = api.spec(spec_request);
4879 assert!(
4880 spec_result2.is_ok(),
4881 "spec() after run should succeed (cache invalidated and rebuilt)"
4882 );
4883 }
4884
4885 #[test]
4886 fn test_file_content_updated_in_context_after_run() {
4887 let (dir, _project) = create_multi_function_project();
4889 let mut api = Api::from_path(dir.path()).unwrap();
4890
4891 let file_id = api.context().files().keys().next().unwrap().clone();
4893 let initial_content = api.context().file(&file_id).unwrap().to_source().unwrap();
4894 assert!(
4895 initial_content.contains("fn alpha()"),
4896 "Initial content should contain 'fn alpha()'"
4897 );
4898
4899 let goal = Goal::new(
4901 "rename alpha to alpha_modified",
4902 Intent::RenameIdent {
4903 symbol_id: None,
4904 symbol_path: None,
4905 target_ident: Some("alpha".to_string()),
4906 to: "alpha_modified".to_string(),
4907 kind: IdentKind::Fn,
4908 },
4909 );
4910
4911 let result = api.execute(goal).unwrap();
4912 assert!(result.success);
4913
4914 let updated_content = api.context().file(&file_id).unwrap().to_source().unwrap();
4916 assert!(
4917 updated_content.contains("fn alpha_modified()"),
4918 "Updated content should contain 'fn alpha_modified()'"
4919 );
4920 assert!(
4921 !updated_content.contains("fn alpha()"),
4922 "Updated content should NOT contain original 'fn alpha()'"
4923 );
4924 }
4925
4926 #[test]
4931 fn test_discover_finds_renamed_symbol() {
4932 let (dir, _project) = create_multi_function_project();
4935 let mut api = Api::from_path(dir.path()).unwrap();
4936
4937 let before = api
4939 .discover(DiscoverRequest {
4940 pattern: "alpha".to_string(),
4941 ..Default::default()
4942 })
4943 .unwrap();
4944 assert!(
4945 before.symbols.iter().any(|s| s.name == "alpha"),
4946 "Should find 'alpha' before rename"
4947 );
4948
4949 let goal = Goal::new(
4951 "rename alpha to omega",
4952 Intent::RenameIdent {
4953 symbol_id: None,
4954 symbol_path: None,
4955 target_ident: Some("alpha".to_string()),
4956 to: "omega".to_string(),
4957 kind: IdentKind::Fn,
4958 },
4959 );
4960
4961 let result = api.execute(goal).unwrap();
4962 assert!(result.success, "Rename should succeed");
4963
4964 let after_new = api
4966 .discover(DiscoverRequest {
4967 pattern: "omega".to_string(),
4968 ..Default::default()
4969 })
4970 .unwrap();
4971 assert!(
4972 after_new.symbols.iter().any(|s| s.name == "omega"),
4973 "Should find 'omega' after rename"
4974 );
4975
4976 let after_old = api
4978 .discover(DiscoverRequest {
4979 pattern: "alpha".to_string(),
4980 ..Default::default()
4981 })
4982 .unwrap();
4983 assert!(
4984 !after_old.symbols.iter().any(|s| s.name == "alpha"),
4985 "Should NOT find 'alpha' in registry after rename"
4986 );
4987 }
4988
4989 #[test]
4990 fn test_registry_updated_after_sequential_renames() {
4991 let (dir, _project) = create_multi_function_project();
4994 let mut api = Api::from_path(dir.path()).unwrap();
4995
4996 let goal1 = Goal::new(
4998 "rename alpha to alpha_new",
4999 Intent::RenameIdent {
5000 symbol_id: None,
5001 symbol_path: None,
5002 target_ident: Some("alpha".to_string()),
5003 to: "alpha_new".to_string(),
5004 kind: IdentKind::Fn,
5005 },
5006 );
5007
5008 let result1 = api.execute(goal1).unwrap();
5009 assert!(result1.success, "First rename should succeed");
5010
5011 let goal2 = Goal::new(
5013 "rename beta to beta_new",
5014 Intent::RenameIdent {
5015 symbol_id: None,
5016 symbol_path: None,
5017 target_ident: Some("beta".to_string()),
5018 to: "beta_new".to_string(),
5019 kind: IdentKind::Fn,
5020 },
5021 );
5022
5023 let result2 = api.execute(goal2).unwrap();
5024 assert!(result2.success, "Second rename should succeed");
5025
5026 let discover_alpha = api
5028 .discover(DiscoverRequest {
5029 pattern: "alpha_new".to_string(),
5030 ..Default::default()
5031 })
5032 .unwrap();
5033 assert!(
5034 discover_alpha.symbols.iter().any(|s| s.name == "alpha_new"),
5035 "Should find 'alpha_new' after rename"
5036 );
5037
5038 let discover_beta = api
5039 .discover(DiscoverRequest {
5040 pattern: "beta_new".to_string(),
5041 ..Default::default()
5042 })
5043 .unwrap();
5044 assert!(
5045 discover_beta.symbols.iter().any(|s| s.name == "beta_new"),
5046 "Should find 'beta_new' after rename"
5047 );
5048
5049 let discover_old_alpha = api
5051 .discover(DiscoverRequest {
5052 pattern: "alpha".to_string(),
5053 ..Default::default()
5054 })
5055 .unwrap();
5056 assert!(
5057 !discover_old_alpha.symbols.iter().any(|s| s.name == "alpha"),
5058 "Should NOT find 'alpha' after rename"
5059 );
5060 }
5061
5062 #[test]
5067 fn test_symbol_id_resolves_to_name() {
5068 let (dir, _project) = create_multi_function_project();
5071 let mut api = Api::from_path(dir.path()).unwrap();
5072
5073 let discover_result = api
5075 .discover(DiscoverRequest {
5076 pattern: "alpha".to_string(),
5077 ..Default::default()
5078 })
5079 .unwrap();
5080
5081 let alpha_symbol = discover_result
5082 .symbols
5083 .iter()
5084 .find(|s| s.name == "alpha")
5085 .expect("Should find 'alpha' symbol");
5086
5087 let goal = Goal::new(
5089 "rename alpha to zeta using symbol_id",
5090 Intent::RenameIdent {
5091 symbol_id: Some(alpha_symbol.id.clone()),
5092 symbol_path: None,
5093 target_ident: None,
5094 to: "zeta".to_string(),
5095 kind: IdentKind::Fn,
5096 },
5097 );
5098
5099 let result = api.execute(goal).unwrap();
5102 assert!(result.success, "Rename using symbol_id should succeed");
5103 assert!(result.total_changes > 0, "Should have made changes");
5104
5105 let discover_zeta = api
5107 .discover(DiscoverRequest {
5108 pattern: "zeta".to_string(),
5109 ..Default::default()
5110 })
5111 .unwrap();
5112 assert!(
5113 discover_zeta.symbols.iter().any(|s| s.name == "zeta"),
5114 "Should find 'zeta' after rename via symbol_id"
5115 );
5116
5117 let discover_alpha = api
5118 .discover(DiscoverRequest {
5119 pattern: "alpha".to_string(),
5120 ..Default::default()
5121 })
5122 .unwrap();
5123 assert!(
5124 !discover_alpha.symbols.iter().any(|s| s.name == "alpha"),
5125 "Should NOT find 'alpha' after rename via symbol_id"
5126 );
5127 }
5128
5129 #[test]
5130 fn test_symbol_id_sequential_operations() {
5131 let (dir, _project) = create_multi_function_project();
5134 let mut api = Api::from_path(dir.path()).unwrap();
5135
5136 let discover1 = api
5138 .discover(DiscoverRequest {
5139 pattern: "alpha".to_string(),
5140 ..Default::default()
5141 })
5142 .unwrap();
5143 let alpha_id_str = discover1
5144 .symbols
5145 .iter()
5146 .find(|s| s.name == "alpha")
5147 .unwrap()
5148 .id
5149 .clone();
5150
5151 let goal1 = Goal::new(
5153 "rename alpha to phi",
5154 Intent::RenameIdent {
5155 symbol_id: Some(alpha_id_str.clone()),
5156 symbol_path: None,
5157 target_ident: None,
5158 to: "phi".to_string(),
5159 kind: IdentKind::Fn,
5160 },
5161 );
5162 let result1 = api.execute(goal1).unwrap();
5163 assert!(result1.success, "First rename should succeed");
5164
5165 let discover2 = api
5167 .discover(DiscoverRequest {
5168 pattern: "phi".to_string(),
5169 ..Default::default()
5170 })
5171 .unwrap();
5172 let phi_id_str = discover2
5173 .symbols
5174 .iter()
5175 .find(|s| s.name == "phi")
5176 .unwrap()
5177 .id
5178 .clone();
5179
5180 assert_eq!(
5182 alpha_id_str, phi_id_str,
5183 "SymbolId should be preserved across renames"
5184 );
5185
5186 let goal2 = Goal::new(
5188 "rename phi to psi",
5189 Intent::RenameIdent {
5190 symbol_id: Some(phi_id_str),
5191 symbol_path: None,
5192 target_ident: None,
5193 to: "psi".to_string(),
5194 kind: IdentKind::Fn,
5195 },
5196 );
5197 let result2 = api.execute(goal2).unwrap();
5198 assert!(result2.success, "Second rename should succeed");
5199
5200 let discover3 = api
5202 .discover(DiscoverRequest {
5203 pattern: "psi".to_string(),
5204 ..Default::default()
5205 })
5206 .unwrap();
5207 assert!(
5208 discover3.symbols.iter().any(|s| s.name == "psi"),
5209 "Should find 'psi' after sequential renames using symbol_id"
5210 );
5211 }
5212
5213 fn create_enum_match_project() -> (tempfile::TempDir, Project) {
5218 let dir = tempdir().unwrap();
5219 let src = dir.path().join("src");
5220 fs::create_dir(&src).unwrap();
5221
5222 fs::write(
5223 dir.path().join("Cargo.toml"),
5224 r#"[package]
5225name = "test-enum-match"
5226version = "0.1.0"
5227edition = "2021"
5228"#,
5229 )
5230 .unwrap();
5231
5232 fs::write(
5233 src.join("lib.rs"),
5234 r#"
5235pub enum Status {
5236 Active,
5237 Inactive,
5238}
5239
5240pub fn handle_status(s: Status) -> &'static str {
5241 match s {
5242 Status::Active => "active",
5243 Status::Inactive => "inactive",
5244 }
5245}
5246
5247pub struct Processor;
5248
5249impl Processor {
5250 pub fn label(s: &Status) -> &'static str {
5251 match s {
5252 Status::Active => "A",
5253 Status::Inactive => "I",
5254 }
5255 }
5256}
5257"#,
5258 )
5259 .unwrap();
5260
5261 let project = Project::load(dir.path()).unwrap();
5262 (dir, project)
5263 }
5264
5265 #[test]
5266 fn test_add_variant_cascade_adds_match_arms() {
5267 let (dir, _project) = create_enum_match_project();
5270 let mut api = Api::from_path(dir.path()).unwrap();
5271
5272 let goal = Goal::new(
5273 "add Pending variant to Status",
5274 Intent::AddVariant {
5275 symbol_id: None,
5276 symbol_path: None,
5277 target_enum: Some("Status".to_string()),
5278 variant_name: "Pending".to_string(),
5279 variant_type: "unit".to_string(),
5280 },
5281 );
5282
5283 let result = api.execute(goal).unwrap();
5284
5285 assert!(
5287 result.success,
5288 "AddVariant with cascade should succeed: {:?}",
5289 result
5290 );
5291
5292 assert!(
5294 result.total_changes >= 3,
5295 "Expected at least 3 changes (1 AddVariant + 2 AddMatchArm), got {}",
5296 result.total_changes
5297 );
5298 }
5299
5300 #[test]
5301 fn test_graph_cascade_enum_returns_match_functions() {
5302 let (dir, _project) = create_enum_match_project();
5305 let mut api = Api::from_path(dir.path()).unwrap();
5306
5307 let discover_result = api
5309 .discover(DiscoverRequest {
5310 pattern: "Status".to_string(),
5311 kind: Some(ryo_analysis::SymbolKind::Enum),
5312 ..Default::default()
5313 })
5314 .unwrap();
5315 let status_sym = discover_result
5316 .symbols
5317 .iter()
5318 .find(|s| s.name == "Status")
5319 .expect("Status enum should exist");
5320
5321 let cascade = api
5322 .graph_cascade(CascadeRequest::new(&status_sym.id))
5323 .unwrap();
5324
5325 assert!(
5327 cascade.match_functions.len() >= 2,
5328 "Expected at least 2 match functions, got {}: {:?}",
5329 cascade.match_functions.len(),
5330 cascade.match_functions
5331 );
5332
5333 let has_handle_status = cascade
5334 .match_functions
5335 .iter()
5336 .any(|f| f.contains("handle_status"));
5337 let has_label = cascade.match_functions.iter().any(|f| f.contains("label"));
5338 assert!(
5339 has_handle_status,
5340 "match_functions should include handle_status: {:?}",
5341 cascade.match_functions
5342 );
5343 assert!(
5344 has_label,
5345 "match_functions should include label: {:?}",
5346 cascade.match_functions
5347 );
5348 }
5349
5350 fn create_tuple_variant_project() -> (tempfile::TempDir, Project) {
5352 let dir = tempdir().unwrap();
5353 let src = dir.path().join("src");
5354 fs::create_dir(&src).unwrap();
5355
5356 fs::write(
5357 dir.path().join("Cargo.toml"),
5358 r#"[package]
5359name = "test-tuple-variant"
5360version = "0.1.0"
5361edition = "2021"
5362"#,
5363 )
5364 .unwrap();
5365
5366 fs::write(
5367 src.join("lib.rs"),
5368 r#"
5369pub enum Message {
5370 Text(String),
5371 Number(i32),
5372 Empty,
5373}
5374
5375pub fn describe(msg: &Message) -> String {
5376 match msg {
5377 Message::Text(s) => format!("text: {}", s),
5378 Message::Number(n) => format!("num: {}", n),
5379 Message::Empty => "empty".to_string(),
5380 }
5381}
5382"#,
5383 )
5384 .unwrap();
5385
5386 let project = Project::load(dir.path()).unwrap();
5387 (dir, project)
5388 }
5389
5390 #[test]
5391 fn test_replace_match_arm_tuple_variant() {
5392 let (dir, _project) = create_tuple_variant_project();
5395 let mut api = Api::from_path(dir.path()).unwrap();
5396
5397 let goal = Goal::new(
5398 "replace Text arm body",
5399 Intent::ReplaceMatchArm {
5400 symbol_id: None,
5401 symbol_path: None,
5402 target_fn: Some("describe".to_string()),
5403 enum_name: "Message".to_string(),
5404 old_pattern: "Message::Text(s)".to_string(),
5405 new_pattern: "Message::Text(s)".to_string(),
5406 new_body: "format!(\"text_v2: {}\", s)".to_string(),
5407 },
5408 );
5409
5410 let result = api.execute(goal).unwrap();
5411 assert!(
5412 result.success && result.total_changes >= 1,
5413 "ReplaceMatchArm with TupleStruct pattern should succeed: {:?}",
5414 result
5415 );
5416 }
5417
5418 #[test]
5419 fn test_replace_match_arm_by_symbol_id() {
5420 let (dir, _project) = create_enum_match_project();
5422 let mut api = Api::from_path(dir.path()).unwrap();
5423
5424 let discover_result = api
5425 .discover(DiscoverRequest {
5426 pattern: "handle_status".to_string(),
5427 ..Default::default()
5428 })
5429 .unwrap();
5430 let fn_sym = discover_result
5431 .symbols
5432 .iter()
5433 .find(|s| s.name == "handle_status")
5434 .expect("handle_status should exist");
5435
5436 let goal = Goal::new(
5437 "replace Active arm body",
5438 Intent::ReplaceMatchArm {
5439 symbol_id: Some(fn_sym.id.clone()),
5440 symbol_path: None,
5441 target_fn: None,
5442 enum_name: "Status".to_string(),
5443 old_pattern: "Status::Active".to_string(),
5444 new_pattern: "Status::Active".to_string(),
5445 new_body: "\"active_v2\"".to_string(),
5446 },
5447 );
5448
5449 let result = api.execute(goal).unwrap();
5450 assert!(
5451 result.success && result.total_changes >= 1,
5452 "ReplaceMatchArm by symbol_id should succeed: {:?}",
5453 result
5454 );
5455 }
5456
5457 #[test]
5458 fn test_replace_match_arm_by_target_fn() {
5459 let (dir, _project) = create_enum_match_project();
5461 let mut api = Api::from_path(dir.path()).unwrap();
5462
5463 let goal = Goal::new(
5464 "replace Active arm body via target_fn",
5465 Intent::ReplaceMatchArm {
5466 symbol_id: None,
5467 symbol_path: None,
5468 target_fn: Some("handle_status".to_string()),
5469 enum_name: "Status".to_string(),
5470 old_pattern: "Status::Active".to_string(),
5471 new_pattern: "Status::Active".to_string(),
5472 new_body: "\"active_v2\"".to_string(),
5473 },
5474 );
5475
5476 let result = api.execute(goal).unwrap();
5477 assert!(
5478 result.success && result.total_changes >= 1,
5479 "ReplaceMatchArm by target_fn should succeed: {:?}",
5480 result
5481 );
5482 }
5483
5484 #[test]
5485 fn test_replace_match_arm_by_symbol_path() {
5486 let (dir, _project) = create_enum_match_project();
5488 let mut api = Api::from_path(dir.path()).unwrap();
5489
5490 let goal = Goal::new(
5491 "replace Active arm body via symbol_path",
5492 Intent::ReplaceMatchArm {
5493 symbol_id: None,
5494 symbol_path: Some("test_enum_match::handle_status".to_string()),
5495 target_fn: None,
5496 enum_name: "Status".to_string(),
5497 old_pattern: "Status::Active".to_string(),
5498 new_pattern: "Status::Active".to_string(),
5499 new_body: "\"active_v2\"".to_string(),
5500 },
5501 );
5502
5503 let result = api.execute(goal).unwrap();
5504 assert!(
5505 result.success && result.total_changes >= 1,
5506 "ReplaceMatchArm by symbol_path should succeed: {:?}",
5507 result
5508 );
5509 }
5510
5511 fn create_multi_match_project() -> (tempfile::TempDir, Project) {
5518 let dir = tempdir().unwrap();
5519 let src = dir.path().join("src");
5520 fs::create_dir(&src).unwrap();
5521 fs::write(
5522 dir.path().join("Cargo.toml"),
5523 r#"[package]
5524name = "test-multi-match"
5525version = "0.1.0"
5526edition = "2021"
5527"#,
5528 )
5529 .unwrap();
5530 fs::write(
5531 src.join("lib.rs"),
5532 r#"
5533pub enum Filter {
5534 Recurse(usize),
5535 Exclude(String),
5536}
5537
5538pub enum FilterKind {
5539 Inclusive,
5540 Exclusive,
5541}
5542
5543pub fn evaluate(f: Filter, name: &str) -> String {
5544 // First: match on Option (unrelated)
5545 let idx = match name.find('/') {
5546 Some(i) => i,
5547 None => 0,
5548 };
5549 // Second: match on Filter (the target)
5550 match f {
5551 Filter::Recurse(depth) => format!("recurse {}", depth),
5552 Filter::Exclude(pat) => format!("exclude {}", pat),
5553 }
5554}
5555
5556pub fn classify(fk: FilterKind) -> &'static str {
5557 // Matches on FilterKind — NOT Filter
5558 // But "FilterKind::Inclusive".contains("Filter") == true (false positive)
5559 match fk {
5560 FilterKind::Inclusive => "inclusive",
5561 FilterKind::Exclusive => "exclusive",
5562 }
5563}
5564
5565pub fn nested_eval(f: Filter) -> String {
5566 // Nested match on the SAME enum — should still produce only 1 arm addition
5567 match f {
5568 Filter::Recurse(depth) => {
5569 match f {
5570 Filter::Recurse(d) => format!("double recurse {}", d),
5571 Filter::Exclude(_) => String::new(),
5572 }
5573 }
5574 Filter::Exclude(pat) => format!("exclude {}", pat),
5575 }
5576}
5577"#,
5578 )
5579 .unwrap();
5580 let project = Project::load(dir.path()).unwrap();
5581 (dir, project)
5582 }
5583
5584 #[test]
5585 fn test_add_variant_cascade_no_overmatch() {
5586 let (dir, _project) = create_multi_match_project();
5592 let mut api = Api::from_path(dir.path()).unwrap();
5593
5594 let goal = Goal::new(
5595 "add Map variant to Filter",
5596 Intent::AddVariant {
5597 symbol_id: None,
5598 symbol_path: None,
5599 target_enum: Some("Filter".to_string()),
5600 variant_name: "Map".to_string(),
5601 variant_type: "tuple:String".to_string(),
5602 },
5603 );
5604
5605 let result = api.execute(goal).unwrap();
5606 assert!(
5607 result.success && result.total_changes >= 1,
5608 "AddVariant cascade should succeed with changes: {:?}",
5609 result
5610 );
5611
5612 let src_content = {
5615 let ctx = api.context();
5616 let mut source = String::new();
5617 for (_wfp, file) in ctx.files().iter() {
5618 source = file.to_source().unwrap();
5619 }
5620 source
5621 };
5622
5623 assert!(
5625 src_content.contains("Map(String)"),
5626 "Filter enum should contain Map(String):\n{}",
5627 src_content
5628 );
5629
5630 let map_arm_count = src_content.matches("Filter::Map").count();
5636 assert_eq!(
5637 map_arm_count, 2,
5638 "Should have exactly 2 Filter::Map arms (evaluate=1, nested_eval=1), got {}:\n{}",
5639 map_arm_count, src_content
5640 );
5641
5642 let classify_start = src_content
5645 .find("fn classify")
5646 .expect("classify should exist");
5647 let classify_end = src_content[classify_start..]
5648 .find("\n}\n")
5649 .map(|i| classify_start + i + 3)
5650 .unwrap_or(src_content.len());
5651 let classify_body = &src_content[classify_start..classify_end];
5652 assert!(
5653 !classify_body.contains("Filter::Map"),
5654 "classify() should NOT have Filter::Map arm (FilterKind != Filter):\n{}",
5655 classify_body
5656 );
5657
5658 let nested_start = src_content
5660 .find("fn nested_eval")
5661 .expect("nested_eval should exist");
5662 let nested_body = &src_content[nested_start..];
5663 let nested_map_count = nested_body.matches("Filter::Map").count();
5664 assert!(
5667 nested_map_count <= 2,
5668 "nested_eval() should have at most 2 Filter::Map arms (one per match), got {}:\n{}",
5669 nested_map_count,
5670 nested_body
5671 );
5672
5673 let has_bare_map = src_content.lines().any(|line| {
5675 let trimmed = line.trim();
5676 trimmed.contains("Map(_)") && !trimmed.contains("Filter::Map")
5677 });
5678 assert!(
5679 !has_bare_map,
5680 "Should not have bare 'Map(_)' without 'Filter::' prefix:\n{}",
5681 src_content
5682 );
5683 }
5684
5685 fn create_insert_statement_project() -> (tempfile::TempDir, Project) {
5690 let dir = tempdir().unwrap();
5691 let src = dir.path().join("src");
5692 fs::create_dir(&src).unwrap();
5693
5694 fs::write(
5695 dir.path().join("Cargo.toml"),
5696 r#"[package]
5697name = "test-insert"
5698version = "0.1.0"
5699edition = "2021"
5700"#,
5701 )
5702 .unwrap();
5703
5704 fs::write(
5705 src.join("lib.rs"),
5706 r#"
5707pub fn setup(config: &mut Config) {
5708 let db = connect_db();
5709 let cache = init_cache();
5710 start_server();
5711}
5712"#,
5713 )
5714 .unwrap();
5715
5716 let project = Project::load(dir.path()).unwrap();
5717 (dir, project)
5718 }
5719
5720 #[test]
5721 fn test_insert_statement_after_pattern_e2e() {
5722 let (dir, _project) = create_insert_statement_project();
5723 let mut api = Api::from_path(dir.path()).unwrap();
5724
5725 let goal = Goal::new(
5726 "insert pool init after db connect",
5727 Intent::InsertStatement {
5728 target_mod: None,
5729 target_fn: "setup".to_string(),
5730 stmt: "let pool = create_pool(&db);".to_string(),
5731 position: StmtInsertPosition::AfterPattern,
5732 reference_pattern: Some("let db = connect_db()".to_string()),
5733 symbol_path: None,
5734 },
5735 );
5736
5737 let result = api.execute(goal).unwrap();
5738 eprintln!("InsertStatement AfterPattern result: {:?}", result);
5739
5740 assert!(
5741 result.success,
5742 "InsertStatement AfterPattern should succeed: {:?}",
5743 result
5744 );
5745 assert!(
5746 result.total_changes >= 1,
5747 "Should have at least 1 change, got {}. Result: {:?}",
5748 result.total_changes,
5749 result
5750 );
5751
5752 let src_content = {
5754 let ctx = api.context();
5755 let mut source = String::new();
5756 for (_wfp, file) in ctx.files().iter() {
5757 source = file.to_source().unwrap();
5758 }
5759 source
5760 };
5761
5762 assert!(
5763 src_content.contains("create_pool"),
5764 "Inserted statement should appear in output:\n{}",
5765 src_content
5766 );
5767
5768 let db_pos = src_content
5770 .find("connect_db")
5771 .expect("connect_db should exist");
5772 let pool_pos = src_content
5773 .find("create_pool")
5774 .expect("create_pool should exist");
5775 assert!(
5776 db_pos < pool_pos,
5777 "connect_db should appear before create_pool:\n{}",
5778 src_content
5779 );
5780 }
5781
5782 #[test]
5783 fn test_insert_statement_after_pattern_macro_e2e() {
5784 let dir = tempdir().unwrap();
5786 let src = dir.path().join("src");
5787 fs::create_dir(&src).unwrap();
5788
5789 fs::write(
5790 dir.path().join("Cargo.toml"),
5791 r#"[package]
5792name = "test-insert-macro"
5793version = "0.1.0"
5794edition = "2021"
5795"#,
5796 )
5797 .unwrap();
5798
5799 fs::write(
5800 src.join("lib.rs"),
5801 r#"
5802pub fn run() {
5803 println!("starting");
5804 do_work();
5805 println!("done");
5806}
5807"#,
5808 )
5809 .unwrap();
5810
5811 let _project = Project::load(dir.path()).unwrap();
5812 let mut api = Api::from_path(dir.path()).unwrap();
5813
5814 let goal = Goal::new(
5815 "insert log after starting",
5816 Intent::InsertStatement {
5817 target_mod: None,
5818 target_fn: "run".to_string(),
5819 stmt: r#"println!("logging");"#.to_string(),
5820 position: StmtInsertPosition::AfterPattern,
5821 reference_pattern: Some(r#"println!("starting")"#.to_string()),
5822 symbol_path: None,
5823 },
5824 );
5825
5826 let result = api.execute(goal).unwrap();
5827 eprintln!("InsertStatement macro AfterPattern result: {:?}", result);
5828
5829 assert!(
5830 result.success,
5831 "InsertStatement macro AfterPattern should succeed: {:?}",
5832 result
5833 );
5834 assert!(
5835 result.total_changes >= 1,
5836 "Should have at least 1 change, got {}. Result: {:?}",
5837 result.total_changes,
5838 result
5839 );
5840 }
5841
5842 #[test]
5844 fn test_insert_statement_json_deserialize_correct_fields() {
5845 let json = serde_json::json!({
5846 "type": "InsertStatement",
5847 "target_fn": "setup",
5848 "stmt": "let pool = create_pool();",
5849 "position": "after_pattern",
5850 "reference_pattern": "let db = connect_db()"
5851 });
5852 let intent: Result<Intent, _> = serde_json::from_value(json);
5853 assert!(
5854 intent.is_ok(),
5855 "Correct field names should deserialize: {:?}",
5856 intent.err()
5857 );
5858 }
5859}