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        let walk = WalkBuilder::new(root)
30            .filter_entry(|e| e.file_name() != ".project-map")
31            .build();
32
33        for result in walk {
34            let entry = match result {
35                Ok(e) => e,
36                Err(_) => continue,
37            };
38
39            let path = entry.path();
40            if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
41                let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
42                if extension == "py" || extension == "rs" || extension == "ts" || extension == "tsx" || extension == "kt" || extension == "sql" || extension == "vue" || extension == "md" {
43                    match self.parser.parse_file(path) {
44                        Ok(outline) => {
45                            let fqn = path_to_fqn(root, path);
46                            let file_node = self.graph.add_node(NodeData {
47                                path: outline.path.clone(),
48                                name: fqn.clone(),
49                                kind: "file".to_string(),
50                                line: 0,
51                                start_byte: 0,
52                                end_byte: 0,
53                                node_type: NodeType::File,
54                                docstring: None,
55                            });
56                            fqn_to_node.insert(fqn, file_node);
57                            path_to_node.insert(outline.path.clone(), file_node);
58
59                            for symbol in &outline.symbols {
60                                let symbol_node = self.graph.add_node(NodeData {
61                                    path: outline.path.clone(),
62                                    name: symbol.name.clone(),
63                                    kind: symbol.kind.clone(),
64                                    line: symbol.line,
65                                    start_byte: symbol.start_byte,
66                                    end_byte: symbol.end_byte,
67                                    node_type: NodeType::Symbol,
68                                    docstring: symbol.docstring.clone(),
69                                });
70                                self.graph.add_edge(file_node, symbol_node, EdgeType::Contains);
71                            }
72                            outlines.push(outline);
73                        }
74                        Err(e) => {
75                            // If it's just invalid UTF-8, we can skip it silently or log it
76                            if !e.to_string().contains("valid UTF-8") {
77                                eprintln!("Error parsing {}: {}", path.display(), e);
78                            }
79                        }
80                    }
81                }
82            }
83        }
84
85        // Pass 2: Resolve imports and create edges
86        for outline in outlines {
87            if let Some(&from_node) = path_to_node.get(&outline.path) {
88                for imp in outline.imports {
89                    // Strategy 1: FQN Match (Python/General)
90                    if let Some(&to_node) = fqn_to_node.get(&imp) {
91                        self.graph.add_edge(from_node, to_node, EdgeType::Imports);
92                    } else {
93                        // Strategy 2: Relative Path Resolution (TypeScript)
94                        let resolved_rel = resolve_import_path(&outline.path, &imp);
95                        
96                        // Try matching resolved path with common TS extensions
97                        let mut found = false;
98                        for ext in &["", ".ts", ".tsx", "/index.ts", "/index.tsx"] {
99                            let candidate = format!("{}{}", resolved_rel, ext);
100                            if let Some(&to_node) = path_to_node.get(&candidate) {
101                                self.graph.add_edge(from_node, to_node, EdgeType::Imports);
102                                found = true;
103                                break;
104                            }
105                        }
106
107                        // Strategy 3: FQN Suffix match (Fallback)
108                        if !found {
109                            let matching_fqn = fqn_to_node.keys()
110                                .find(|&k| k.ends_with(&imp));
111                            if let Some(key) = matching_fqn {
112                                let &to_node = fqn_to_node.get(key).unwrap();
113                                self.graph.add_edge(from_node, to_node, EdgeType::Imports);
114                            }
115                        }
116                    }
117                }
118            }
119        }
120
121        Ok(())
122    }
123
124    pub fn is_effectively_empty(&self, root: &Path) -> bool {
125        let walk = WalkBuilder::new(root)
126            .filter_entry(|e| {
127                let name = e.file_name();
128                name != ".project-map" && name != ".git"
129            })
130            .build();
131
132        for result in walk {
133            let entry = match result {
134                Ok(e) => e,
135                Err(_) => continue,
136            };
137
138            if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
139                // If we find ANY non-hidden file (WalkBuilder respects .gitignore), it's not empty
140                return false;
141            }
142        }
143        true
144    }
145
146    pub fn scaffold_if_empty(&self, root: &Path) -> Result<bool> {
147        if self.is_effectively_empty(root) {
148            let arch_path = root.join("ARCHITECTURE.md");
149            if !arch_path.exists() {
150                let content = "# Project Architecture\n\nThis is a placeholder for the project's architectural overview. Use this file to define the system structure, core components, and design principles.\n";
151                std::fs::write(&arch_path, content)?;
152                return Ok(true);
153            }
154        }
155        Ok(false)
156    }
157
158    pub fn save_index(&self, path: &Path) -> Result<()> {
159        self.graph.save(path)
160    }
161
162    pub fn save_index_versioned(&self, base_dir: &Path) -> Result<()> {
163        use chrono::Local;
164        use std::fs;
165        use std::os::unix::fs::symlink;
166
167        let timestamp = Local::now().format("%Y%m%d_%H%M%S").to_string();
168        let backups_dir = base_dir.join("backups");
169        let current_backup_dir = backups_dir.join(&timestamp);
170        
171        fs::create_dir_all(&current_backup_dir)?;
172        
173        let index_path = current_backup_dir.join(".project-map.json");
174        self.graph.save(&index_path)?;
175
176        // Limit backups to 5
177        if let Ok(entries) = fs::read_dir(&backups_dir) {
178            let mut dirs: Vec<_> = entries.filter_map(|e| e.ok()).collect();
179            dirs.sort_by_key(|e| e.metadata().and_then(|m| m.modified()).unwrap_or_else(|_| std::time::SystemTime::UNIX_EPOCH));
180            
181            while dirs.len() > 5 {
182                let oldest = dirs.remove(0);
183                fs::remove_dir_all(oldest.path()).ok();
184            }
185        }
186
187        let latest_link = base_dir.join("latest");
188        if latest_link.exists() {
189            fs::remove_file(&latest_link).ok();
190            fs::remove_dir_all(&latest_link).ok();
191        }
192
193        // On Unix, use a symlink. 
194        #[cfg(unix)]
195        {
196            // We want the symlink to be relative so it's portable
197            let rel_target = Path::new("backups").join(&timestamp);
198            symlink(rel_target, &latest_link)?;
199        }
200
201        // Fallback for non-Unix or if symlink fails
202        #[cfg(not(unix))]
203        {
204            fs::create_dir_all(&latest_link)?;
205            fs::copy(&index_path, latest_link.join(".project-map.json"))?;
206        }
207
208        Ok(())
209    }
210}