Skip to main content

project_map_cli_rust/core/
orchestrator.rs

1use std::path::Path;
2use std::collections::HashMap;
3use ignore::WalkBuilder;
4use crate::core::parser::CodeParser;
5use crate::core::graph::{ProjectGraph, NodeData, NodeType, EdgeType};
6use crate::core::utils::{path_to_fqn, resolve_import_path};
7use crate::error::Result;
8
9pub struct Orchestrator {
10    parser: CodeParser,
11    graph: ProjectGraph,
12}
13
14impl Orchestrator {
15    pub fn new() -> Self {
16        Self {
17            parser: CodeParser::new(),
18            graph: ProjectGraph::new(),
19        }
20    }
21
22    pub fn build_index(&mut self, root: &Path) -> Result<()> {
23        let mut outlines = Vec::new();
24        let mut fqn_to_node = HashMap::new();
25        let mut path_to_node = HashMap::new();
26
27        // Pass 1: Parse all files and create nodes
28        // WalkBuilder respects .gitignore by default
29        for result in WalkBuilder::new(root).build() {
30            let entry = match result {
31                Ok(e) => e,
32                Err(_) => continue,
33            };
34
35            let path = entry.path();
36            if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
37                let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
38                if extension == "py" || extension == "rs" || extension == "ts" || extension == "tsx" || extension == "kt" || extension == "sql" || extension == "vue" {
39                    match self.parser.parse_file(path) {
40                        Ok(outline) => {
41                            let fqn = path_to_fqn(root, path);
42                            let file_node = self.graph.add_node(NodeData {
43                                path: outline.path.clone(),
44                                name: fqn.clone(),
45                                kind: "file".to_string(),
46                                line: 0,
47                                start_byte: 0,
48                                end_byte: 0,
49                                node_type: NodeType::File,
50                            });
51                            fqn_to_node.insert(fqn, file_node);
52                            path_to_node.insert(outline.path.clone(), file_node);
53
54                            for symbol in &outline.symbols {
55                                let symbol_node = self.graph.add_node(NodeData {
56                                    path: outline.path.clone(),
57                                    name: symbol.name.clone(),
58                                    kind: symbol.kind.clone(),
59                                    line: symbol.line,
60                                    start_byte: symbol.start_byte,
61                                    end_byte: symbol.end_byte,
62                                    node_type: NodeType::Symbol,
63                                });
64                                self.graph.add_edge(file_node, symbol_node, EdgeType::Contains);
65                            }
66                            outlines.push(outline);
67                        }
68                        Err(e) => {
69                            // If it's just invalid UTF-8, we can skip it silently or log it
70                            if !e.to_string().contains("valid UTF-8") {
71                                eprintln!("Error parsing {}: {}", path.display(), e);
72                            }
73                        }
74                    }
75                }
76            }
77        }
78
79        // Pass 2: Resolve imports and create edges
80        for outline in outlines {
81            if let Some(&from_node) = path_to_node.get(&outline.path) {
82                for imp in outline.imports {
83                    // Strategy 1: FQN Match (Python/General)
84                    if let Some(&to_node) = fqn_to_node.get(&imp) {
85                        self.graph.add_edge(from_node, to_node, EdgeType::Imports);
86                    } else {
87                        // Strategy 2: Relative Path Resolution (TypeScript)
88                        let resolved_rel = resolve_import_path(&outline.path, &imp);
89                        
90                        // Try matching resolved path with common TS extensions
91                        let mut found = false;
92                        for ext in &["", ".ts", ".tsx", "/index.ts", "/index.tsx"] {
93                            let candidate = format!("{}{}", resolved_rel, ext);
94                            if let Some(&to_node) = path_to_node.get(&candidate) {
95                                self.graph.add_edge(from_node, to_node, EdgeType::Imports);
96                                found = true;
97                                break;
98                            }
99                        }
100
101                        // Strategy 3: FQN Suffix match (Fallback)
102                        if !found {
103                            let matching_fqn = fqn_to_node.keys()
104                                .find(|&k| k.ends_with(&imp));
105                            if let Some(key) = matching_fqn {
106                                let &to_node = fqn_to_node.get(key).unwrap();
107                                self.graph.add_edge(from_node, to_node, EdgeType::Imports);
108                            }
109                        }
110                    }
111                }
112            }
113        }
114
115        Ok(())
116    }
117
118    pub fn save_index(&self, path: &Path) -> Result<()> {
119        self.graph.save(path)
120    }
121
122    pub fn save_index_versioned(&self, base_dir: &Path) -> Result<()> {
123        use chrono::Local;
124        use std::fs;
125        use std::os::unix::fs::symlink;
126
127        let timestamp = Local::now().format("%Y%m%d_%H%M%S").to_string();
128        let backups_dir = base_dir.join("backups");
129        let current_backup_dir = backups_dir.join(&timestamp);
130        
131        fs::create_dir_all(&current_backup_dir)?;
132        
133        let index_path = current_backup_dir.join(".project-map.json");
134        self.graph.save(&index_path)?;
135
136        let latest_link = base_dir.join("latest");
137        if latest_link.exists() {
138            fs::remove_file(&latest_link).ok();
139            fs::remove_dir_all(&latest_link).ok();
140        }
141
142        // On Unix, use a symlink. 
143        #[cfg(unix)]
144        {
145            // We want the symlink to be relative so it's portable
146            let rel_target = Path::new("backups").join(&timestamp);
147            symlink(rel_target, &latest_link)?;
148        }
149
150        // Fallback for non-Unix or if symlink fails
151        #[cfg(not(unix))]
152        {
153            fs::create_dir_all(&latest_link)?;
154            fs::copy(&index_path, latest_link.join(".project-map.json"))?;
155        }
156
157        Ok(())
158    }
159}