1use crate::{
9 endpoints::{ControllerInfo, EndpointMetadata, EndpointParameter, ParameterSource},
10 error::{OpenApiError, OpenApiResult},
11};
12use std::fs;
13use std::path::{Path, PathBuf};
14use toml;
15
16pub struct ProjectDiscovery {
18 project_root: PathBuf,
20}
21
22#[derive(Debug, Clone)]
24pub struct ProjectStructure {
25 pub controllers: Vec<ControllerInfo>,
27 pub models: Vec<ModelInfo>,
29 pub metadata: ProjectMetadata,
31}
32
33#[derive(Debug, Clone)]
35pub struct ModelInfo {
36 pub name: String,
38 pub fields: Vec<ModelField>,
40 pub documentation: Option<String>,
42 pub derives: Vec<String>,
44}
45
46#[derive(Debug, Clone)]
48pub struct ModelField {
49 pub name: String,
51 pub field_type: String,
53 pub documentation: Option<String>,
55 pub optional: bool,
57}
58
59#[derive(Debug, Clone)]
61pub struct ProjectMetadata {
62 pub name: String,
64 pub version: String,
66 pub description: Option<String>,
68 pub authors: Vec<String>,
70}
71
72impl ProjectDiscovery {
73 pub fn new<P: AsRef<Path>>(project_root: P) -> Self {
75 Self {
76 project_root: project_root.as_ref().to_path_buf(),
77 }
78 }
79
80 pub fn discover(&self) -> OpenApiResult<ProjectStructure> {
82 let metadata = self.discover_project_metadata()?;
83 let controllers = self.discover_controllers()?;
84 let models = self.discover_models()?;
85
86 Ok(ProjectStructure {
87 controllers,
88 models,
89 metadata,
90 })
91 }
92
93 fn discover_project_metadata(&self) -> OpenApiResult<ProjectMetadata> {
95 let cargo_toml_path = self.project_root.join("Cargo.toml");
96
97 if !cargo_toml_path.exists() {
98 return Ok(ProjectMetadata {
99 name: "Unknown".to_string(),
100 version: "1.0.0".to_string(),
101 description: None,
102 authors: Vec::new(),
103 });
104 }
105
106 let cargo_content = fs::read_to_string(&cargo_toml_path).map_err(|e| {
107 OpenApiError::route_discovery_error(format!("Failed to read Cargo.toml: {}", e))
108 })?;
109
110 let toml_value: toml::Value = cargo_content.parse().map_err(|e| {
112 OpenApiError::route_discovery_error(format!("Failed to parse Cargo.toml: {}", e))
113 })?;
114
115 let package = toml_value.get("package").ok_or_else(|| {
117 OpenApiError::route_discovery_error(
118 "No [package] section found in Cargo.toml".to_string(),
119 )
120 })?;
121
122 let name = package
123 .get("name")
124 .and_then(|v| v.as_str())
125 .unwrap_or("Unknown")
126 .to_string();
127
128 let version = package
129 .get("version")
130 .and_then(|v| v.as_str())
131 .unwrap_or("1.0.0")
132 .to_string();
133
134 let description = package
135 .get("description")
136 .and_then(|v| v.as_str())
137 .map(|s| s.to_string());
138
139 let authors = package
141 .get("authors")
142 .and_then(|v| v.as_array())
143 .map(|authors_array| {
144 authors_array
145 .iter()
146 .filter_map(|author| author.as_str())
147 .map(|s| s.to_string())
148 .collect()
149 })
150 .unwrap_or_else(Vec::new);
151
152 Ok(ProjectMetadata {
153 name,
154 version,
155 description,
156 authors,
157 })
158 }
159
160 fn discover_controllers(&self) -> OpenApiResult<Vec<ControllerInfo>> {
162 let controllers_dir = self.project_root.join("src").join("controllers");
163
164 if !controllers_dir.exists() {
165 return Ok(Vec::new());
166 }
167
168 let mut controllers = Vec::new();
169
170 let entries = fs::read_dir(&controllers_dir).map_err(|e| {
171 OpenApiError::route_discovery_error(format!(
172 "Failed to read controllers directory: {}",
173 e
174 ))
175 })?;
176
177 for entry in entries {
178 let entry = entry.map_err(|e| {
179 OpenApiError::route_discovery_error(format!(
180 "Failed to read controller entry: {}",
181 e
182 ))
183 })?;
184
185 let path = entry.path();
186 if path.extension().map(|ext| ext == "rs").unwrap_or(false) {
187 if let Some(controller) = self.analyze_controller_file(&path)? {
188 controllers.push(controller);
189 }
190 }
191 }
192
193 Ok(controllers)
194 }
195
196 fn analyze_controller_file(&self, path: &Path) -> OpenApiResult<Option<ControllerInfo>> {
198 let content = fs::read_to_string(path).map_err(|e| {
199 OpenApiError::route_discovery_error(format!(
200 "Failed to read controller file {}: {}",
201 path.display(),
202 e
203 ))
204 })?;
205
206 let controller_name = path
207 .file_stem()
208 .and_then(|name| name.to_str())
209 .unwrap_or("Unknown")
210 .replace("_controller", "")
211 .replace("_", " ")
212 .split_whitespace()
213 .map(capitalize)
214 .collect::<String>();
215
216 let endpoints = self.extract_endpoints_from_content(&content)?;
217
218 if endpoints.is_empty() {
219 return Ok(None);
220 }
221
222 let mut controller = ControllerInfo::new(&controller_name);
223 for endpoint in endpoints {
224 controller = controller.add_endpoint(endpoint);
225 }
226
227 Ok(Some(controller))
228 }
229
230 fn extract_endpoints_from_content(
232 &self,
233 content: &str,
234 ) -> OpenApiResult<Vec<EndpointMetadata>> {
235 let ast = syn::parse_file(content).map_err(|e| {
237 OpenApiError::route_discovery_error(format!("Failed to parse Rust file: {}", e))
238 })?;
239
240 let mut endpoints = Vec::new();
241
242 for item in &ast.items {
244 if let syn::Item::Fn(func) = item {
245 if let Some(endpoint) = self.extract_endpoint_from_function(func)? {
246 endpoints.push(endpoint);
247 }
248 }
249 }
250
251 Ok(endpoints)
252 }
253
254 fn extract_endpoint_from_function(
256 &self,
257 func: &syn::ItemFn,
258 ) -> OpenApiResult<Option<EndpointMetadata>> {
259 let mut route_info = None;
261
262 for attr in &func.attrs {
263 if let Some((verb, path)) = self.parse_route_attribute_ast(attr)? {
264 route_info = Some((verb, path));
265 break;
266 }
267 }
268
269 let Some((verb, path)) = route_info else {
270 return Ok(None);
271 };
272
273 let function_name = func.sig.ident.to_string();
275
276 let mut endpoint = EndpointMetadata::new(&function_name, &verb, &path);
278
279 let params = self.extract_function_parameters_ast(&func.sig)?;
281 for param in params {
282 endpoint = endpoint.with_parameter(param);
283 }
284
285 let doc = self.extract_documentation_ast(&func.attrs);
287 if let Some(doc) = doc {
288 endpoint = endpoint.with_documentation(&doc);
289 }
290
291 if let syn::ReturnType::Type(_, ty) = &func.sig.output {
293 endpoint.return_type = Some(self.type_to_string(ty));
294 }
295
296 Ok(Some(endpoint))
297 }
298
299 fn parse_route_attribute_ast(
301 &self,
302 attr: &syn::Attribute,
303 ) -> OpenApiResult<Option<(String, String)>> {
304 let path_segments: Vec<String> = attr
306 .path()
307 .segments
308 .iter()
309 .map(|seg| seg.ident.to_string())
310 .collect();
311
312 if path_segments.len() != 1 {
314 return Ok(None);
315 }
316
317 let attr_name = &path_segments[0];
318 let (verb, path) = match attr_name.as_str() {
319 "route" => {
320 self.parse_route_macro(attr)?
322 }
323 "get" => ("GET".to_string(), self.parse_simple_route_macro(attr)?),
324 "post" => ("POST".to_string(), self.parse_simple_route_macro(attr)?),
325 "put" => ("PUT".to_string(), self.parse_simple_route_macro(attr)?),
326 "delete" => ("DELETE".to_string(), self.parse_simple_route_macro(attr)?),
327 "patch" => ("PATCH".to_string(), self.parse_simple_route_macro(attr)?),
328 "head" => ("HEAD".to_string(), self.parse_simple_route_macro(attr)?),
329 "options" => ("OPTIONS".to_string(), self.parse_simple_route_macro(attr)?),
330 _ => return Ok(None),
331 };
332
333 Ok(Some((verb, path)))
334 }
335
336 fn parse_route_macro(&self, attr: &syn::Attribute) -> OpenApiResult<(String, String)> {
338 match &attr.meta {
339 syn::Meta::List(meta_list) => {
340 let tokens = &meta_list.tokens;
341 let token_str = tokens.to_string();
342
343 let parts: Vec<&str> = token_str.split(',').map(|s| s.trim()).collect();
345 if parts.len() >= 2 {
346 let verb = parts[0].trim_matches('"').to_uppercase();
347 let path = parts[1].trim_matches('"').to_string();
348 Ok((verb, path))
349 } else {
350 Err(OpenApiError::route_discovery_error(
351 "Invalid route attribute format".to_string(),
352 ))
353 }
354 }
355 _ => Err(OpenApiError::route_discovery_error(
356 "Expected route attribute with arguments".to_string(),
357 )),
358 }
359 }
360
361 fn parse_simple_route_macro(&self, attr: &syn::Attribute) -> OpenApiResult<String> {
363 match &attr.meta {
364 syn::Meta::List(meta_list) => {
365 let tokens = &meta_list.tokens;
366 let path = tokens.to_string().trim_matches('"').to_string();
367 Ok(path)
368 }
369 _ => Err(OpenApiError::route_discovery_error(
370 "Expected route attribute with path".to_string(),
371 )),
372 }
373 }
374
375 fn extract_function_parameters_ast(
377 &self,
378 sig: &syn::Signature,
379 ) -> OpenApiResult<Vec<EndpointParameter>> {
380 let mut parameters = Vec::new();
381
382 for input in &sig.inputs {
383 match input {
384 syn::FnArg::Typed(pat_type) => {
385 let param_name = match &*pat_type.pat {
386 syn::Pat::Ident(ident) => ident.ident.to_string(),
387 _ => continue, };
389
390 let type_str = self.type_to_string(&pat_type.ty);
391 let (source, optional) = self.determine_parameter_source(&type_str);
392
393 parameters.push(EndpointParameter {
394 name: param_name,
395 param_type: type_str,
396 source,
397 optional,
398 documentation: None,
399 });
400 }
401 syn::FnArg::Receiver(_) => continue, }
403 }
404
405 Ok(parameters)
406 }
407
408 fn determine_parameter_source(&self, type_str: &str) -> (ParameterSource, bool) {
410 if type_str.contains("Path<") || type_str.contains("PathParams") {
411 (ParameterSource::Path, false)
412 } else if type_str.contains("Query<") || type_str.contains("QueryParams") {
413 (ParameterSource::Query, type_str.contains("Option<"))
414 } else if type_str.contains("Header<") || type_str.contains("HeaderMap") {
415 (ParameterSource::Header, type_str.contains("Option<"))
416 } else if type_str.contains("Json<")
417 || type_str.contains("Form<")
418 || type_str.contains("Request")
419 {
420 (ParameterSource::Body, false)
421 } else {
422 (ParameterSource::Query, type_str.contains("Option<"))
424 }
425 }
426
427 fn type_to_string(&self, ty: &syn::Type) -> String {
429 quote::quote!(#ty).to_string()
430 }
431
432 fn extract_documentation_ast(&self, attrs: &[syn::Attribute]) -> Option<String> {
434 let mut doc_lines = Vec::new();
435
436 for attr in attrs {
437 if attr.path().is_ident("doc") {
438 if let syn::Meta::NameValue(meta) = &attr.meta {
439 if let syn::Expr::Lit(syn::ExprLit {
440 lit: syn::Lit::Str(lit_str),
441 ..
442 }) = &meta.value
443 {
444 doc_lines.push(lit_str.value().trim().to_string());
445 }
446 }
447 }
448 }
449
450 if doc_lines.is_empty() {
451 None
452 } else {
453 Some(doc_lines.join("\n"))
454 }
455 }
456
457 fn discover_models(&self) -> OpenApiResult<Vec<ModelInfo>> {
459 let models_dir = self.project_root.join("src").join("models");
460
461 if !models_dir.exists() {
462 return Ok(Vec::new());
463 }
464
465 let mut models = Vec::new();
466
467 let entries = fs::read_dir(&models_dir).map_err(|e| {
468 OpenApiError::route_discovery_error(format!("Failed to read models directory: {}", e))
469 })?;
470
471 for entry in entries {
472 let entry = entry.map_err(|e| {
473 OpenApiError::route_discovery_error(format!("Failed to read model entry: {}", e))
474 })?;
475
476 let path = entry.path();
477 if path.extension().map(|ext| ext == "rs").unwrap_or(false) {
478 if let Some(model) = self.analyze_model_file(&path)? {
479 models.push(model);
480 }
481 }
482 }
483
484 Ok(models)
485 }
486
487 fn analyze_model_file(&self, path: &Path) -> OpenApiResult<Option<ModelInfo>> {
489 let content = fs::read_to_string(path).map_err(|e| {
490 OpenApiError::route_discovery_error(format!(
491 "Failed to read model file {}: {}",
492 path.display(),
493 e
494 ))
495 })?;
496
497 let model_name = path
498 .file_stem()
499 .and_then(|name| name.to_str())
500 .unwrap_or("Unknown")
501 .to_string();
502
503 let ast = syn::parse_file(&content).map_err(|e| {
505 OpenApiError::route_discovery_error(format!(
506 "Failed to parse model file {}: {}",
507 path.display(),
508 e
509 ))
510 })?;
511
512 if let Some(model) = self.extract_struct_from_ast(&ast, &model_name)? {
514 return Ok(Some(model));
515 }
516
517 Ok(None)
518 }
519
520 fn extract_struct_from_ast(
522 &self,
523 ast: &syn::File,
524 model_name: &str,
525 ) -> OpenApiResult<Option<ModelInfo>> {
526 for item in &ast.items {
528 if let syn::Item::Struct(item_struct) = item {
529 let struct_name = item_struct.ident.to_string();
530
531 if struct_name.to_lowercase() == model_name.to_lowercase() {
533 let derives = self.extract_derives_from_attrs(&item_struct.attrs);
535
536 let doc = self.extract_documentation_ast(&item_struct.attrs);
538
539 let fields = self.extract_struct_fields_from_ast(&item_struct.fields)?;
541
542 return Ok(Some(ModelInfo {
543 name: struct_name,
544 fields,
545 documentation: doc,
546 derives,
547 }));
548 }
549 }
550 }
551
552 Ok(None)
553 }
554
555 fn extract_derives_from_attrs(&self, attrs: &[syn::Attribute]) -> Vec<String> {
557 let mut derives = Vec::new();
558
559 for attr in attrs {
560 if attr.path().is_ident("derive") {
561 if let syn::Meta::List(meta_list) = &attr.meta {
562 let derive_tokens = meta_list.tokens.to_string();
563 derives.extend(
565 derive_tokens
566 .split(',')
567 .map(|d| d.trim().to_string())
568 .filter(|d| !d.is_empty()),
569 );
570 }
571 }
572 }
573
574 derives
575 }
576
577 fn extract_struct_fields_from_ast(
579 &self,
580 fields: &syn::Fields,
581 ) -> OpenApiResult<Vec<ModelField>> {
582 let mut model_fields = Vec::new();
583
584 match fields {
585 syn::Fields::Named(fields_named) => {
586 for field in &fields_named.named {
587 if let Some(field_name) = &field.ident {
588 let field_name = field_name.to_string();
589 let field_type = self.type_to_string(&field.ty);
590 let optional =
591 field_type.starts_with("Option<") || field_type.contains("Option <");
592
593 let documentation = self.extract_documentation_ast(&field.attrs);
595
596 model_fields.push(ModelField {
597 name: field_name,
598 field_type,
599 documentation,
600 optional,
601 });
602 }
603 }
604 }
605 syn::Fields::Unnamed(fields_unnamed) => {
606 for (index, field) in fields_unnamed.unnamed.iter().enumerate() {
608 let field_name = format!("field_{}", index);
609 let field_type = self.type_to_string(&field.ty);
610 let optional =
611 field_type.starts_with("Option<") || field_type.contains("Option <");
612
613 let documentation = self.extract_documentation_ast(&field.attrs);
615
616 model_fields.push(ModelField {
617 name: field_name,
618 field_type,
619 documentation,
620 optional,
621 });
622 }
623 }
624 syn::Fields::Unit => {
625 }
627 }
628
629 Ok(model_fields)
630 }
631
632 #[allow(dead_code)]
635 fn extract_documentation_from_lines(
636 &self,
637 lines: &[&str],
638 route_index: usize,
639 ) -> Option<String> {
640 let mut doc_lines = Vec::new();
641
642 for i in (0..route_index).rev() {
644 let line = lines[i].trim();
645 if line.starts_with("///") {
646 doc_lines.insert(0, line.trim_start_matches("///").trim());
647 } else if line.starts_with("//!") {
648 doc_lines.insert(0, line.trim_start_matches("//!").trim());
649 } else if !line.is_empty() && !line.starts_with("//") {
650 break;
651 }
652 }
653
654 if doc_lines.is_empty() {
655 None
656 } else {
657 Some(doc_lines.join(" "))
658 }
659 }
660}
661
662fn capitalize(s: &str) -> String {
664 let mut chars = s.chars();
665 match chars.next() {
666 None => String::new(),
667 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use std::fs;
675 use tempfile::TempDir;
676
677 #[test]
678 fn test_project_discovery_creation() {
679 let temp_dir = TempDir::new().unwrap();
680 let discovery = ProjectDiscovery::new(temp_dir.path());
681 assert_eq!(discovery.project_root, temp_dir.path());
682 }
683
684 #[test]
685 fn test_ast_based_struct_parsing() {
686 let discovery = ProjectDiscovery::new(".");
687
688 let test_code = r#"
689 #[derive(Debug, Clone, Serialize)]
690 /// A test user model
691 pub struct User {
692 /// User ID
693 pub id: i32,
694 /// User email address
695 pub email: Option<String>,
696 pub name: String,
697 }
698 "#;
699
700 let ast = syn::parse_file(test_code).unwrap();
701 let model = discovery
702 .extract_struct_from_ast(&ast, "user")
703 .unwrap()
704 .unwrap();
705
706 assert_eq!(model.name, "User");
707 assert_eq!(model.fields.len(), 3);
708 assert!(model.derives.contains(&"Debug".to_string()));
709 assert!(model.derives.contains(&"Clone".to_string()));
710 assert!(model.derives.contains(&"Serialize".to_string()));
711 assert!(model.documentation.is_some());
712
713 let id_field = model.fields.iter().find(|f| f.name == "id").unwrap();
715 assert_eq!(id_field.field_type, "i32");
716 assert!(!id_field.optional);
717
718 let email_field = model.fields.iter().find(|f| f.name == "email").unwrap();
719 assert_eq!(email_field.field_type, "Option < String >");
720 assert!(email_field.optional);
721 }
722
723 #[test]
724 fn test_robust_toml_parsing() {
725 let temp_dir = TempDir::new().unwrap();
727 let cargo_toml_path = temp_dir.path().join("Cargo.toml");
728
729 let complex_toml_content = r#"
730# This is a comment
731[package]
732name = "test-project" # Inline comment
733version = "1.2.3"
734description = "A test project with complex TOML structure"
735authors = ["John Doe <john@example.com>", "Jane Smith <jane@example.com>"]
736
737# Some other sections that should not interfere
738[dependencies]
739serde = "1.0"
740
741[dev-dependencies]
742tokio-test = "0.4"
743
744# Another comment
745[features]
746default = []
747 "#;
748
749 fs::write(&cargo_toml_path, complex_toml_content).unwrap();
750
751 let discovery = ProjectDiscovery::new(temp_dir.path());
752 let metadata = discovery.discover_project_metadata().unwrap();
753
754 assert_eq!(metadata.name, "test-project");
755 assert_eq!(metadata.version, "1.2.3");
756 assert_eq!(
757 metadata.description,
758 Some("A test project with complex TOML structure".to_string())
759 );
760 assert_eq!(metadata.authors.len(), 2);
761 assert!(metadata
762 .authors
763 .contains(&"John Doe <john@example.com>".to_string()));
764 assert!(metadata
765 .authors
766 .contains(&"Jane Smith <jane@example.com>".to_string()));
767 }
768
769 #[test]
770 fn test_toml_parsing_with_missing_package_section() {
771 let temp_dir = TempDir::new().unwrap();
772 let cargo_toml_path = temp_dir.path().join("Cargo.toml");
773
774 let invalid_toml_content = r#"
775# No package section
776[dependencies]
777serde = "1.0"
778 "#;
779
780 fs::write(&cargo_toml_path, invalid_toml_content).unwrap();
781
782 let discovery = ProjectDiscovery::new(temp_dir.path());
783 let result = discovery.discover_project_metadata();
784
785 assert!(result.is_err());
786 assert!(result
787 .unwrap_err()
788 .to_string()
789 .contains("No [package] section found"));
790 }
791
792 #[test]
793 fn test_toml_parsing_with_minimal_package() {
794 let temp_dir = TempDir::new().unwrap();
795 let cargo_toml_path = temp_dir.path().join("Cargo.toml");
796
797 let minimal_toml_content = r#"
798[package]
799name = "minimal-project"
800version = "0.1.0"
801 "#;
802
803 fs::write(&cargo_toml_path, minimal_toml_content).unwrap();
804
805 let discovery = ProjectDiscovery::new(temp_dir.path());
806 let metadata = discovery.discover_project_metadata().unwrap();
807
808 assert_eq!(metadata.name, "minimal-project");
809 assert_eq!(metadata.version, "0.1.0");
810 assert_eq!(metadata.description, None);
811 assert!(metadata.authors.is_empty());
812 }
813
814 #[test]
815 fn test_toml_parsing_with_different_key_ordering() {
816 let temp_dir = TempDir::new().unwrap();
817 let cargo_toml_path = temp_dir.path().join("Cargo.toml");
818
819 let reordered_toml_content = r#"
821[package]
822authors = ["Author One"]
823description = "Description first"
824name = "reordered-project"
825version = "2.0.0"
826 "#;
827
828 fs::write(&cargo_toml_path, reordered_toml_content).unwrap();
829
830 let discovery = ProjectDiscovery::new(temp_dir.path());
831 let metadata = discovery.discover_project_metadata().unwrap();
832
833 assert_eq!(metadata.name, "reordered-project");
834 assert_eq!(metadata.version, "2.0.0");
835 assert_eq!(metadata.description, Some("Description first".to_string()));
836 assert_eq!(metadata.authors, vec!["Author One".to_string()]);
837 }
838}