codeprysm_core/
manifest.rs

1//! Manifest Parser for Component Extraction
2//!
3//! This module parses manifest files (package.json, Cargo.toml, go.mod, etc.)
4//! to extract component metadata and local dependencies for building the
5//! component graph with DependsOn edges.
6//!
7//! ## Supported Manifest Files
8//!
9//! | Filename | Language | Ecosystem |
10//! |----------|----------|-----------|
11//! | package.json | Json | npm/Node.js |
12//! | vcpkg.json | Json | vcpkg (C/C++) |
13//! | Cargo.toml | Toml | Rust |
14//! | pyproject.toml | Toml | Python |
15//! | go.mod | GoMod | Go |
16//! | *.csproj/*.vbproj/*.fsproj | Xml | .NET |
17//! | CMakeLists.txt | CMake | CMake |
18//!
19//! ## Usage
20//!
21//! ```rust,ignore
22//! use codeprysm_core::manifest::{ManifestParser, ManifestInfo};
23//! use std::path::Path;
24//!
25//! let content = r#"{"name": "my-package", "version": "1.0.0"}"#;
26//! let path = Path::new("package.json");
27//!
28//! let mut parser = ManifestParser::new()?;
29//! let info = parser.parse(path, content)?;
30//! println!("Component: {:?}", info.component_name);
31//! ```
32
33use std::path::Path;
34
35use thiserror::Error;
36use tree_sitter::{Parser, Query, QueryCursor, StreamingIterator};
37
38use crate::embedded_queries::get_manifest_query;
39use crate::parser::ManifestLanguage;
40
41// ============================================================================
42// Errors
43// ============================================================================
44
45/// Errors that can occur during manifest parsing.
46#[derive(Debug, Error)]
47pub enum ManifestError {
48    /// Manifest file type not recognized
49    #[error("Unrecognized manifest file: {0}")]
50    UnrecognizedManifest(String),
51
52    /// Failed to parse manifest content
53    #[error("Failed to parse manifest: {0}")]
54    ParseFailed(String),
55
56    /// Failed to compile query
57    #[error("Failed to compile manifest query: {0}")]
58    QueryCompileFailed(String),
59
60    /// Failed to set parser language
61    #[error("Failed to set parser language: {0}")]
62    LanguageSetFailed(String),
63}
64
65// ============================================================================
66// Dependency Types
67// ============================================================================
68
69/// Type of local dependency reference.
70///
71/// These are the types of dependencies that create DependsOn edges
72/// between components in the graph.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub enum DependencyType {
75    /// Path-based dependency (Cargo: `{ path = "../sibling" }`)
76    Path,
77    /// Workspace dependency (npm: `workspace:*`, Cargo workspace member)
78    Workspace,
79    /// Project reference (.NET: `<ProjectReference Include="..." />`)
80    ProjectReference,
81    /// Replace directive (Go: `replace module => ../local`)
82    Replace,
83    /// Subdirectory dependency (CMake: `add_subdirectory(../shared)`)
84    Subdirectory,
85}
86
87impl DependencyType {
88    /// Get string representation
89    pub fn as_str(&self) -> &'static str {
90        match self {
91            DependencyType::Path => "path",
92            DependencyType::Workspace => "workspace",
93            DependencyType::ProjectReference => "project_reference",
94            DependencyType::Replace => "replace",
95            DependencyType::Subdirectory => "subdirectory",
96        }
97    }
98}
99
100impl std::fmt::Display for DependencyType {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        write!(f, "{}", self.as_str())
103    }
104}
105
106// ============================================================================
107// Local Dependency
108// ============================================================================
109
110/// A local dependency extracted from a manifest file.
111///
112/// Local dependencies are references to other components in the same
113/// repository or workspace, which create DependsOn edges in the graph.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct LocalDependency {
116    /// Name of the dependency (package/crate/module name)
117    pub name: String,
118    /// Relative path to the dependency (if specified)
119    pub path: Option<String>,
120    /// Type of dependency reference
121    pub dep_type: DependencyType,
122    /// Whether this is a dev dependency
123    pub is_dev: bool,
124    /// Whether this is a build dependency
125    pub is_build: bool,
126    /// Original version/spec string (for context)
127    pub version_spec: Option<String>,
128}
129
130impl LocalDependency {
131    /// Create a new local dependency
132    pub fn new(name: String, dep_type: DependencyType) -> Self {
133        Self {
134            name,
135            path: None,
136            dep_type,
137            is_dev: false,
138            is_build: false,
139            version_spec: None,
140        }
141    }
142
143    /// Create with a path
144    pub fn with_path(name: String, path: String, dep_type: DependencyType) -> Self {
145        Self {
146            name,
147            path: Some(path),
148            dep_type,
149            is_dev: false,
150            is_build: false,
151            version_spec: None,
152        }
153    }
154
155    /// Set as dev dependency
156    pub fn as_dev(mut self) -> Self {
157        self.is_dev = true;
158        self
159    }
160
161    /// Set as build dependency
162    pub fn as_build(mut self) -> Self {
163        self.is_build = true;
164        self
165    }
166
167    /// Set version spec
168    pub fn with_version(mut self, version: String) -> Self {
169        self.version_spec = Some(version);
170        self
171    }
172}
173
174// ============================================================================
175// Manifest Info
176// ============================================================================
177
178/// Information extracted from a manifest file.
179///
180/// Contains component metadata and local dependencies for graph construction.
181#[derive(Debug, Clone, Default)]
182pub struct ManifestInfo {
183    /// Component name (package/crate/module name)
184    pub component_name: Option<String>,
185    /// Component version
186    pub version: Option<String>,
187    /// Whether this is a workspace root
188    pub is_workspace_root: bool,
189    /// Workspace member paths (for workspace roots)
190    pub workspace_members: Vec<String>,
191    /// Local dependencies that create DependsOn edges
192    pub local_dependencies: Vec<LocalDependency>,
193    /// Ecosystem identifier (npm, cargo, python, go, dotnet, cmake)
194    pub ecosystem: Option<String>,
195}
196
197impl ManifestInfo {
198    /// Create an empty ManifestInfo
199    pub fn new() -> Self {
200        Self::default()
201    }
202
203    /// Check if any useful information was extracted
204    pub fn is_empty(&self) -> bool {
205        self.component_name.is_none()
206            && self.version.is_none()
207            && !self.is_workspace_root
208            && self.workspace_members.is_empty()
209            && self.local_dependencies.is_empty()
210    }
211
212    /// Check if this manifest defines a publishable component
213    pub fn is_publishable(&self) -> bool {
214        self.component_name.is_some()
215    }
216}
217
218// ============================================================================
219// Manifest Parser
220// ============================================================================
221
222/// Parser for extracting component information from manifest files.
223///
224/// Uses tree-sitter with embedded SCM queries to parse various manifest
225/// file formats and extract component names, versions, and local dependencies.
226pub struct ManifestParser {
227    /// Reusable tree-sitter parser
228    parser: Parser,
229}
230
231impl ManifestParser {
232    /// Create a new manifest parser.
233    pub fn new() -> Result<Self, ManifestError> {
234        Ok(Self {
235            parser: Parser::new(),
236        })
237    }
238
239    /// Parse a manifest file and extract component information.
240    ///
241    /// # Arguments
242    ///
243    /// * `path` - Path to the manifest file (used to detect language)
244    /// * `content` - Content of the manifest file
245    ///
246    /// # Returns
247    ///
248    /// `ManifestInfo` containing extracted component metadata and dependencies.
249    pub fn parse(&mut self, path: &Path, content: &str) -> Result<ManifestInfo, ManifestError> {
250        // Detect manifest language from filename
251        let language = ManifestLanguage::from_path(path)
252            .ok_or_else(|| ManifestError::UnrecognizedManifest(path.display().to_string()))?;
253
254        self.parse_with_language(content, language)
255    }
256
257    /// Parse manifest content with a known language.
258    ///
259    /// # Arguments
260    ///
261    /// * `content` - Content of the manifest file
262    /// * `language` - The manifest language to use for parsing
263    ///
264    /// # Returns
265    ///
266    /// `ManifestInfo` containing extracted component metadata and dependencies.
267    pub fn parse_with_language(
268        &mut self,
269        content: &str,
270        language: ManifestLanguage,
271    ) -> Result<ManifestInfo, ManifestError> {
272        // Set up parser with manifest grammar
273        self.parser
274            .set_language(&language.tree_sitter_language())
275            .map_err(|e| ManifestError::LanguageSetFailed(e.to_string()))?;
276
277        // Parse content into AST
278        let tree = self
279            .parser
280            .parse(content, None)
281            .ok_or_else(|| ManifestError::ParseFailed("tree-sitter parse returned None".into()))?;
282
283        // Compile manifest query
284        let query_source = get_manifest_query(language);
285        let query = Query::new(&language.tree_sitter_language(), query_source)
286            .map_err(|e| ManifestError::QueryCompileFailed(format!("{:?}", e)))?;
287
288        // Run query and collect captures
289        let mut cursor = QueryCursor::new();
290        let source_bytes = content.as_bytes();
291        let capture_names = query.capture_names();
292
293        let mut info = ManifestInfo::new();
294        info.ecosystem = Some(language.as_str().to_string());
295
296        // Track paired captures for path dependencies
297        let mut pending_path_deps: std::collections::HashMap<String, PendingPathDep> =
298            std::collections::HashMap::new();
299
300        let mut matches = cursor.matches(&query, tree.root_node(), source_bytes);
301        while let Some(match_) = matches.next() {
302            for capture in match_.captures {
303                let capture_name = &capture_names[capture.index as usize];
304                let text = capture
305                    .node
306                    .utf8_text(source_bytes)
307                    .unwrap_or("")
308                    .trim_matches('"')
309                    .to_string();
310
311                self.process_capture(capture_name, &text, &mut info, &mut pending_path_deps);
312            }
313        }
314
315        // Finalize any pending path dependencies
316        for (_, pending) in pending_path_deps {
317            if let (Some(name), Some(path)) = (pending.name, pending.path) {
318                let mut dep = LocalDependency::with_path(name, path, DependencyType::Path);
319                dep.is_dev = pending.is_dev;
320                dep.is_build = pending.is_build;
321                info.local_dependencies.push(dep);
322            }
323        }
324
325        Ok(info)
326    }
327
328    /// Process a single capture from the query results.
329    fn process_capture(
330        &self,
331        capture_name: &str,
332        text: &str,
333        info: &mut ManifestInfo,
334        pending_path_deps: &mut std::collections::HashMap<String, PendingPathDep>,
335    ) {
336        // Skip internal captures (starting with _)
337        if capture_name.starts_with('_') {
338            return;
339        }
340
341        // Parse capture name to determine what was captured
342        // Format: manifest.{element}.{ecosystem}[.{modifier}]
343        let parts: Vec<&str> = capture_name.split('.').collect();
344        if parts.is_empty() || parts[0] != "manifest" {
345            return;
346        }
347
348        // Handle different capture patterns
349        match parts.get(1).copied() {
350            Some("component") => self.process_component_capture(&parts[2..], text, info),
351            Some("workspace") => self.process_workspace_capture(&parts[2..], text, info),
352            Some("dependency") => {
353                self.process_dependency_capture(&parts[2..], text, info, pending_path_deps)
354            }
355            _ => {}
356        }
357    }
358
359    /// Process component-related captures (name, version, namespace).
360    fn process_component_capture(&self, parts: &[&str], text: &str, info: &mut ManifestInfo) {
361        match parts.first().copied() {
362            Some("name") => {
363                // manifest.component.name.{ecosystem}
364                if info.component_name.is_none() {
365                    info.component_name = Some(text.to_string());
366                }
367            }
368            Some("version") => {
369                // manifest.component.version.{ecosystem}
370                if info.version.is_none() {
371                    info.version = Some(text.to_string());
372                }
373            }
374            Some("namespace") => {
375                // manifest.component.namespace.{ecosystem} (fallback for .NET)
376                if info.component_name.is_none() {
377                    info.component_name = Some(text.to_string());
378                }
379            }
380            Some("packageversion") => {
381                // manifest.component.packageversion.{ecosystem}
382                if info.version.is_none() {
383                    info.version = Some(text.to_string());
384                }
385            }
386            _ => {}
387        }
388    }
389
390    /// Process workspace-related captures (root, member).
391    fn process_workspace_capture(&self, parts: &[&str], text: &str, info: &mut ManifestInfo) {
392        match parts.first().copied() {
393            Some("root") => {
394                // manifest.workspace.root.{ecosystem}
395                info.is_workspace_root = true;
396            }
397            Some("member") => {
398                // manifest.workspace.member.{ecosystem}
399                info.workspace_members.push(text.to_string());
400            }
401            _ => {}
402        }
403    }
404
405    /// Process dependency-related captures.
406    fn process_dependency_capture(
407        &self,
408        parts: &[&str],
409        text: &str,
410        info: &mut ManifestInfo,
411        pending_path_deps: &mut std::collections::HashMap<String, PendingPathDep>,
412    ) {
413        if parts.is_empty() {
414            return;
415        }
416
417        // Determine ecosystem from first part after "dependency"
418        let ecosystem = parts[0];
419        let remaining = &parts[1..];
420
421        match ecosystem {
422            "cargo" => self.process_cargo_dependency(remaining, text, info, pending_path_deps),
423            "gomod" => self.process_gomod_dependency(remaining, text, info),
424            "poetry" => self.process_poetry_dependency(remaining, text, info, pending_path_deps),
425            "local" => self.process_local_dependency(remaining, text, info, pending_path_deps),
426            "projectref" => self.process_projectref_dependency(remaining, text, info),
427            "cmake" => self.process_cmake_dependency(remaining, text, info),
428            _ => {}
429        }
430    }
431
432    /// Process Cargo.toml dependencies.
433    fn process_cargo_dependency(
434        &self,
435        parts: &[&str],
436        text: &str,
437        info: &mut ManifestInfo,
438        pending_path_deps: &mut std::collections::HashMap<String, PendingPathDep>,
439    ) {
440        // Patterns:
441        // - path.name, path.value (path dependencies)
442        // - path.dev.name, path.dev.value (dev path dependencies)
443        // - name, version (regular dependencies - not local)
444        // - dev.name, dev.version (dev dependencies - not local)
445
446        match parts {
447            ["path", "name"] => {
448                let key = format!("cargo:path:{}", text);
449                pending_path_deps.entry(key).or_default().name = Some(text.to_string());
450            }
451            ["path", "value"] => {
452                // Need to find the matching name - use a different approach
453                // For now, create a dependency with just the path
454                // The name will be inferred from the path
455                let name = infer_name_from_path(text);
456                info.local_dependencies.push(LocalDependency::with_path(
457                    name,
458                    text.to_string(),
459                    DependencyType::Path,
460                ));
461            }
462            ["path", "dev", "name"] => {
463                let key = format!("cargo:path:dev:{}", text);
464                let entry = pending_path_deps.entry(key).or_default();
465                entry.name = Some(text.to_string());
466                entry.is_dev = true;
467            }
468            ["path", "dev", "value"] => {
469                let name = infer_name_from_path(text);
470                info.local_dependencies.push(
471                    LocalDependency::with_path(name, text.to_string(), DependencyType::Path)
472                        .as_dev(),
473                );
474            }
475            _ => {} // Ignore non-local dependencies
476        }
477    }
478
479    /// Process go.mod dependencies.
480    fn process_gomod_dependency(&self, parts: &[&str], text: &str, info: &mut ManifestInfo) {
481        // Patterns:
482        // - replace.local (local path from replace directive)
483        // - replace.from (module being replaced)
484        // - replace.multi.local, replace.multi.from (multi-line replace)
485
486        match parts {
487            ["replace", "local"] | ["replace", "multi", "local"] => {
488                // Local file path replacement
489                let name = infer_name_from_path(text);
490                info.local_dependencies.push(LocalDependency::with_path(
491                    name,
492                    text.to_string(),
493                    DependencyType::Replace,
494                ));
495            }
496            _ => {} // Ignore non-local dependencies
497        }
498    }
499
500    /// Process Poetry path dependencies.
501    fn process_poetry_dependency(
502        &self,
503        parts: &[&str],
504        text: &str,
505        info: &mut ManifestInfo,
506        pending_path_deps: &mut std::collections::HashMap<String, PendingPathDep>,
507    ) {
508        match parts {
509            ["path", "name"] => {
510                let key = format!("poetry:path:{}", text);
511                pending_path_deps.entry(key).or_default().name = Some(text.to_string());
512            }
513            ["path", "value"] => {
514                let name = infer_name_from_path(text);
515                info.local_dependencies.push(LocalDependency::with_path(
516                    name,
517                    text.to_string(),
518                    DependencyType::Path,
519                ));
520            }
521            _ => {}
522        }
523    }
524
525    /// Process npm local dependencies (workspace:*, file:, link:).
526    fn process_local_dependency(
527        &self,
528        parts: &[&str],
529        text: &str,
530        info: &mut ManifestInfo,
531        pending_path_deps: &mut std::collections::HashMap<String, PendingPathDep>,
532    ) {
533        match parts {
534            ["name"] => {
535                // Track the name for pairing with version
536                let key = "npm:local:pending".to_string();
537                pending_path_deps.entry(key).or_default().name = Some(text.to_string());
538            }
539            ["version"] => {
540                // Get the pending name
541                let pending_name = pending_path_deps
542                    .remove("npm:local:pending")
543                    .and_then(|p| p.name);
544
545                // Check for local dependency markers
546                if text.starts_with("workspace:") {
547                    // Workspace dependency - use captured name
548                    let name = pending_name.unwrap_or_else(|| {
549                        // Fallback to the version string without prefix
550                        text.trim_start_matches("workspace:").to_string()
551                    });
552                    info.local_dependencies
553                        .push(LocalDependency::new(name, DependencyType::Workspace));
554                } else if text.starts_with("file:") || text.starts_with("link:") {
555                    let path = text.trim_start_matches("file:").trim_start_matches("link:");
556                    let name = pending_name.unwrap_or_else(|| infer_name_from_path(path));
557                    info.local_dependencies.push(LocalDependency::with_path(
558                        name,
559                        path.to_string(),
560                        DependencyType::Path,
561                    ));
562                }
563            }
564            _ => {}
565        }
566    }
567
568    /// Process .NET ProjectReference dependencies.
569    fn process_projectref_dependency(&self, parts: &[&str], text: &str, info: &mut ManifestInfo) {
570        if let ["dotnet"] = parts {
571            // <ProjectReference Include="..\..\Shared\Shared.csproj" />
572            // The text is the Include attribute value (relative path)
573            let name = infer_name_from_csproj_path(text);
574            info.local_dependencies.push(LocalDependency::with_path(
575                name,
576                text.to_string(),
577                DependencyType::ProjectReference,
578            ));
579        }
580    }
581
582    /// Process CMake subdirectory dependencies.
583    fn process_cmake_dependency(&self, parts: &[&str], text: &str, info: &mut ManifestInfo) {
584        if let ["subdirectory"] = parts {
585            // add_subdirectory(../shared) or add_subdirectory(libs/utils)
586            let name = infer_name_from_path(text);
587            info.local_dependencies.push(LocalDependency::with_path(
588                name,
589                text.to_string(),
590                DependencyType::Subdirectory,
591            ));
592        }
593    }
594}
595
596// ============================================================================
597// Helper Types
598// ============================================================================
599
600/// Pending path dependency waiting for paired captures.
601#[derive(Debug, Default)]
602struct PendingPathDep {
603    name: Option<String>,
604    path: Option<String>,
605    is_dev: bool,
606    is_build: bool,
607}
608
609// ============================================================================
610// Helper Functions
611// ============================================================================
612
613/// Infer component name from a path.
614///
615/// Takes the last path component as the name.
616fn infer_name_from_path(path: &str) -> String {
617    // Normalize path separators
618    let path = path.replace('\\', "/");
619
620    // Get last path component
621    path.rsplit('/')
622        .find(|s| !s.is_empty())
623        .unwrap_or(&path)
624        .to_string()
625}
626
627/// Infer component name from a .csproj path.
628///
629/// Removes the .csproj extension and takes the last path component.
630fn infer_name_from_csproj_path(path: &str) -> String {
631    let path = path.replace('\\', "/");
632    let filename = path.rsplit('/').find(|s| !s.is_empty()).unwrap_or(&path);
633
634    // Remove .csproj/.vbproj/.fsproj extension
635    filename
636        .strip_suffix(".csproj")
637        .or_else(|| filename.strip_suffix(".vbproj"))
638        .or_else(|| filename.strip_suffix(".fsproj"))
639        .unwrap_or(filename)
640        .to_string()
641}
642
643// ============================================================================
644// Tests
645// ============================================================================
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    // ========================================================================
652    // ManifestParser Tests
653    // ========================================================================
654
655    #[test]
656    fn test_parse_package_json_simple() {
657        let content = r#"{"name": "my-package", "version": "1.0.0"}"#;
658        let path = Path::new("package.json");
659
660        let mut parser = ManifestParser::new().unwrap();
661        let info = parser.parse(path, content).unwrap();
662
663        assert_eq!(info.component_name, Some("my-package".to_string()));
664        assert_eq!(info.version, Some("1.0.0".to_string()));
665        assert!(!info.is_workspace_root);
666    }
667
668    #[test]
669    fn test_parse_package_json_with_workspaces() {
670        let content = r#"{
671            "name": "monorepo",
672            "version": "1.0.0",
673            "workspaces": ["packages/*", "apps/*"]
674        }"#;
675        let path = Path::new("package.json");
676
677        let mut parser = ManifestParser::new().unwrap();
678        let info = parser.parse(path, content).unwrap();
679
680        assert_eq!(info.component_name, Some("monorepo".to_string()));
681        assert!(info.workspace_members.contains(&"packages/*".to_string()));
682        assert!(info.workspace_members.contains(&"apps/*".to_string()));
683    }
684
685    #[test]
686    fn test_parse_cargo_toml_simple() {
687        let content = r#"
688[package]
689name = "my-crate"
690version = "0.1.0"
691"#;
692        let path = Path::new("Cargo.toml");
693
694        let mut parser = ManifestParser::new().unwrap();
695        let info = parser.parse(path, content).unwrap();
696
697        assert_eq!(info.component_name, Some("my-crate".to_string()));
698        assert_eq!(info.version, Some("0.1.0".to_string()));
699    }
700
701    #[test]
702    fn test_parse_cargo_toml_workspace() {
703        let content = r#"
704[workspace]
705members = ["crates/*", "examples/*"]
706"#;
707        let path = Path::new("Cargo.toml");
708
709        let mut parser = ManifestParser::new().unwrap();
710        let info = parser.parse(path, content).unwrap();
711
712        assert!(info.is_workspace_root);
713        assert!(info.workspace_members.contains(&"crates/*".to_string()));
714        assert!(info.workspace_members.contains(&"examples/*".to_string()));
715    }
716
717    #[test]
718    fn test_parse_go_mod() {
719        let content = r#"module github.com/myorg/myproject
720
721go 1.21
722"#;
723        let path = Path::new("go.mod");
724
725        let mut parser = ManifestParser::new().unwrap();
726        let info = parser.parse(path, content).unwrap();
727
728        assert_eq!(
729            info.component_name,
730            Some("github.com/myorg/myproject".to_string())
731        );
732    }
733
734    #[test]
735    fn test_parse_go_mod_with_replace() {
736        let content = r#"module github.com/myorg/myproject
737
738go 1.21
739
740replace github.com/myorg/shared => ../shared
741"#;
742        let path = Path::new("go.mod");
743
744        let mut parser = ManifestParser::new().unwrap();
745        let info = parser.parse(path, content).unwrap();
746
747        assert_eq!(info.local_dependencies.len(), 1);
748        let dep = &info.local_dependencies[0];
749        assert_eq!(dep.path, Some("../shared".to_string()));
750        assert_eq!(dep.dep_type, DependencyType::Replace);
751    }
752
753    #[test]
754    fn test_parse_csproj() {
755        let content = r#"<Project Sdk="Microsoft.NET.Sdk">
756  <PropertyGroup>
757    <AssemblyName>MyProject</AssemblyName>
758    <Version>1.0.0</Version>
759  </PropertyGroup>
760  <ItemGroup>
761    <ProjectReference Include="..\Shared\Shared.csproj" />
762  </ItemGroup>
763</Project>"#;
764        let path = Path::new("MyProject.csproj");
765
766        let mut parser = ManifestParser::new().unwrap();
767        let info = parser.parse(path, content).unwrap();
768
769        assert_eq!(info.component_name, Some("MyProject".to_string()));
770        assert_eq!(info.version, Some("1.0.0".to_string()));
771        assert_eq!(info.local_dependencies.len(), 1);
772
773        let dep = &info.local_dependencies[0];
774        assert_eq!(dep.name, "Shared");
775        assert_eq!(dep.dep_type, DependencyType::ProjectReference);
776    }
777
778    #[test]
779    fn test_parse_cmake() {
780        let content = r#"cmake_minimum_required(VERSION 3.20)
781project(my-project VERSION 1.0.0)
782add_subdirectory(../shared shared_build)
783"#;
784        let path = Path::new("CMakeLists.txt");
785
786        let mut parser = ManifestParser::new().unwrap();
787        let info = parser.parse(path, content).unwrap();
788
789        assert_eq!(info.component_name, Some("my-project".to_string()));
790        // CMake query captures all arguments from add_subdirectory
791        // (both source_dir and optional binary_dir)
792        assert!(!info.local_dependencies.is_empty());
793
794        // First captured should be the source directory
795        let dep = info
796            .local_dependencies
797            .iter()
798            .find(|d| d.path.as_deref() == Some("../shared"))
799            .expect("Should find ../shared dependency");
800        assert_eq!(dep.dep_type, DependencyType::Subdirectory);
801    }
802
803    #[test]
804    fn test_parse_pyproject_toml() {
805        let content = r#"
806[project]
807name = "my-package"
808version = "1.0.0"
809"#;
810        let path = Path::new("pyproject.toml");
811
812        let mut parser = ManifestParser::new().unwrap();
813        let info = parser.parse(path, content).unwrap();
814
815        assert_eq!(info.component_name, Some("my-package".to_string()));
816        assert_eq!(info.version, Some("1.0.0".to_string()));
817    }
818
819    // ========================================================================
820    // Helper Function Tests
821    // ========================================================================
822
823    #[test]
824    fn test_infer_name_from_path() {
825        assert_eq!(infer_name_from_path("../shared"), "shared");
826        assert_eq!(infer_name_from_path("packages/core"), "core");
827        assert_eq!(infer_name_from_path("..\\shared"), "shared");
828        assert_eq!(infer_name_from_path("./libs/utils"), "utils");
829        assert_eq!(infer_name_from_path("sibling"), "sibling");
830    }
831
832    #[test]
833    fn test_infer_name_from_csproj_path() {
834        assert_eq!(
835            infer_name_from_csproj_path("..\\Shared\\Shared.csproj"),
836            "Shared"
837        );
838        assert_eq!(infer_name_from_csproj_path("../Core/Core.vbproj"), "Core");
839        assert_eq!(infer_name_from_csproj_path("Utils.fsproj"), "Utils");
840    }
841
842    // ========================================================================
843    // DependencyType Tests
844    // ========================================================================
845
846    #[test]
847    fn test_dependency_type_as_str() {
848        assert_eq!(DependencyType::Path.as_str(), "path");
849        assert_eq!(DependencyType::Workspace.as_str(), "workspace");
850        assert_eq!(
851            DependencyType::ProjectReference.as_str(),
852            "project_reference"
853        );
854        assert_eq!(DependencyType::Replace.as_str(), "replace");
855        assert_eq!(DependencyType::Subdirectory.as_str(), "subdirectory");
856    }
857
858    #[test]
859    fn test_dependency_type_display() {
860        assert_eq!(format!("{}", DependencyType::Path), "path");
861        assert_eq!(format!("{}", DependencyType::Workspace), "workspace");
862    }
863
864    // ========================================================================
865    // LocalDependency Tests
866    // ========================================================================
867
868    #[test]
869    fn test_local_dependency_new() {
870        let dep = LocalDependency::new("my-dep".to_string(), DependencyType::Path);
871        assert_eq!(dep.name, "my-dep");
872        assert_eq!(dep.dep_type, DependencyType::Path);
873        assert!(!dep.is_dev);
874        assert!(!dep.is_build);
875        assert!(dep.path.is_none());
876    }
877
878    #[test]
879    fn test_local_dependency_with_path() {
880        let dep = LocalDependency::with_path(
881            "my-dep".to_string(),
882            "../sibling".to_string(),
883            DependencyType::Path,
884        );
885        assert_eq!(dep.path, Some("../sibling".to_string()));
886    }
887
888    #[test]
889    fn test_local_dependency_as_dev() {
890        let dep = LocalDependency::new("my-dep".to_string(), DependencyType::Path).as_dev();
891        assert!(dep.is_dev);
892        assert!(!dep.is_build);
893    }
894
895    #[test]
896    fn test_local_dependency_as_build() {
897        let dep = LocalDependency::new("my-dep".to_string(), DependencyType::Path).as_build();
898        assert!(!dep.is_dev);
899        assert!(dep.is_build);
900    }
901
902    // ========================================================================
903    // ManifestInfo Tests
904    // ========================================================================
905
906    #[test]
907    fn test_manifest_info_is_empty() {
908        let info = ManifestInfo::new();
909        assert!(info.is_empty());
910
911        let mut info_with_name = ManifestInfo::new();
912        info_with_name.component_name = Some("test".to_string());
913        assert!(!info_with_name.is_empty());
914    }
915
916    #[test]
917    fn test_manifest_info_is_publishable() {
918        let mut info = ManifestInfo::new();
919        assert!(!info.is_publishable());
920
921        info.component_name = Some("my-package".to_string());
922        assert!(info.is_publishable());
923    }
924
925    // ========================================================================
926    // Unrecognized Manifest Tests
927    // ========================================================================
928
929    #[test]
930    fn test_unrecognized_manifest() {
931        let mut parser = ManifestParser::new().unwrap();
932        let result = parser.parse(Path::new("README.md"), "# Hello");
933        assert!(matches!(
934            result,
935            Err(ManifestError::UnrecognizedManifest(_))
936        ));
937    }
938}