elif_openapi/
discovery.rs

1/*!
2Project discovery and introspection for OpenAPI generation.
3
4This module provides functionality to discover API routes, controllers, and models
5from an elif.rs project structure.
6*/
7
8use crate::{
9    endpoints::{ControllerInfo, EndpointMetadata, EndpointParameter, ParameterSource},
10    error::{OpenApiError, OpenApiResult},
11};
12use std::fs;
13use std::path::{Path, PathBuf};
14use toml;
15
16/// Project discovery service for analyzing elif.rs projects
17pub struct ProjectDiscovery {
18    /// Project root directory
19    project_root: PathBuf,
20}
21
22/// Discovered project structure
23#[derive(Debug, Clone)]
24pub struct ProjectStructure {
25    /// Controllers found in the project
26    pub controllers: Vec<ControllerInfo>,
27    /// Models/schemas found in the project
28    pub models: Vec<ModelInfo>,
29    /// Project metadata
30    pub metadata: ProjectMetadata,
31}
32
33/// Model/schema information
34#[derive(Debug, Clone)]
35pub struct ModelInfo {
36    /// Model name
37    pub name: String,
38    /// Fields in the model
39    pub fields: Vec<ModelField>,
40    /// Model documentation
41    pub documentation: Option<String>,
42    /// Model attributes/derives
43    pub derives: Vec<String>,
44}
45
46/// Model field information
47#[derive(Debug, Clone)]
48pub struct ModelField {
49    /// Field name
50    pub name: String,
51    /// Field type
52    pub field_type: String,
53    /// Field documentation
54    pub documentation: Option<String>,
55    /// Whether field is optional
56    pub optional: bool,
57}
58
59/// Project metadata
60#[derive(Debug, Clone)]
61pub struct ProjectMetadata {
62    /// Project name
63    pub name: String,
64    /// Project version
65    pub version: String,
66    /// Project description
67    pub description: Option<String>,
68    /// Authors
69    pub authors: Vec<String>,
70}
71
72impl ProjectDiscovery {
73    /// Create new project discovery service
74    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    /// Discover project structure
81    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    /// Discover project metadata from Cargo.toml using proper TOML parsing
94    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        // Parse TOML properly using toml crate
111        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        // Extract package information from [package] table
116        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        // Extract authors array
140        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    /// Discover controllers from src/controllers directory
161    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    /// Analyze a controller file
197    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    /// Extract endpoints from controller file content using AST parsing
231    fn extract_endpoints_from_content(
232        &self,
233        content: &str,
234    ) -> OpenApiResult<Vec<EndpointMetadata>> {
235        // Parse the Rust source code into an AST
236        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        // Walk the AST to find functions with route attributes
243        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    /// Extract endpoint from a function using AST analysis
255    fn extract_endpoint_from_function(
256        &self,
257        func: &syn::ItemFn,
258    ) -> OpenApiResult<Option<EndpointMetadata>> {
259        // Look for route attributes
260        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        // Get function name
274        let function_name = func.sig.ident.to_string();
275
276        // Create endpoint metadata
277        let mut endpoint = EndpointMetadata::new(&function_name, &verb, &path);
278
279        // Extract parameters from function signature
280        let params = self.extract_function_parameters_ast(&func.sig)?;
281        for param in params {
282            endpoint = endpoint.with_parameter(param);
283        }
284
285        // Extract documentation from function attributes
286        let doc = self.extract_documentation_ast(&func.attrs);
287        if let Some(doc) = doc {
288            endpoint = endpoint.with_documentation(&doc);
289        }
290
291        // Extract return type information
292        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    /// Parse route attribute using AST
300    fn parse_route_attribute_ast(
301        &self,
302        attr: &syn::Attribute,
303    ) -> OpenApiResult<Option<(String, String)>> {
304        // Check if this is a route attribute
305        let path_segments: Vec<String> = attr
306            .path()
307            .segments
308            .iter()
309            .map(|seg| seg.ident.to_string())
310            .collect();
311
312        // Look for route-like attributes (route, get, post, put, delete, etc.)
313        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                // Parse #[route(GET, "/path")] or #[route(method = "GET", path = "/path")]
321                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    /// Parse route macro like #[route(GET, "/path")]
337    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                // Simple parsing for now - can be enhanced
344                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    /// Parse simple route macro like #[get("/path")]
362    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    /// Extract function parameters using AST analysis
376    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, // Skip complex patterns
388                    };
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, // Skip self parameters
402            }
403        }
404
405        Ok(parameters)
406    }
407
408    /// Determine parameter source from type information
409    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            // Default to query parameter
423            (ParameterSource::Query, type_str.contains("Option<"))
424        }
425    }
426
427    /// Convert syn::Type to string representation
428    fn type_to_string(&self, ty: &syn::Type) -> String {
429        quote::quote!(#ty).to_string()
430    }
431
432    /// Extract documentation from function attributes
433    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    /// Discover models from src/models directory
458    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    /// Analyze a model file using AST parsing
488    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        // Parse the Rust source code into an AST
504        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        // Extract struct definition using AST
513        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    /// Extract struct definition from AST
521    fn extract_struct_from_ast(
522        &self,
523        ast: &syn::File,
524        model_name: &str,
525    ) -> OpenApiResult<Option<ModelInfo>> {
526        // Walk the AST to find struct definitions
527        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                // Check if this is the struct we're looking for (case-insensitive)
532                if struct_name.to_lowercase() == model_name.to_lowercase() {
533                    // Extract derive attributes
534                    let derives = self.extract_derives_from_attrs(&item_struct.attrs);
535
536                    // Extract documentation
537                    let doc = self.extract_documentation_ast(&item_struct.attrs);
538
539                    // Extract fields
540                    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    /// Extract derive attributes from struct attributes using AST
556    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                    // Parse comma-separated derive tokens
564                    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    /// Extract struct fields from AST Fields
578    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                        // Extract field documentation
594                        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                // Handle tuple structs
607                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                    // Extract field documentation
614                    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                // Unit structs have no fields
626            }
627        }
628
629        Ok(model_fields)
630    }
631
632    /// Bridge function for old line-based documentation extraction (used by model parsing)
633    /// TODO: Replace with AST-based model parsing
634    #[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        // Look backwards for documentation comments
643        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
662/// Helper function to capitalize first letter
663fn 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        // Check fields
714        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        // Test with realistic Cargo.toml content including comments, tables, and various TOML features
726        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        // Test with different key ordering than typical
820        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}