Skip to main content

ryo_app/
project.rs

1//! Project: In-memory collection of AST files with workspace metadata
2//!
3//! Provides file collection with I/O capabilities.
4//! Does NOT include transformation logic (use ryo-executor).
5//! Does NOT include search logic (use ryo-analysis).
6//!
7//! # Design
8//!
9//! In the Context-centric design (Phase 5), `Project` serves as an I/O adapter:
10//! - Loading: `Project::load()` → files + metadata used to create `AnalysisContext`
11//! - Writing: `Project::write_from_context()` → sync Context changes to disk
12//!
13//! The `AnalysisContext` is the Single Source of Truth for file content.
14//!
15//! # Workspace Detection
16//!
17//! `Project` uses `ryo_symbol::CargoMetadataProvider` for accurate workspace detection:
18//! 1. If `ryo.toml` exists, use `manifest_path` or `workspace_root` settings
19//! 2. Otherwise, detect `Cargo.toml` from the given path
20//!
21//! This ensures consistent workspace root detection across all components.
22
23use crate::config::RyoConfig;
24use ryo_source::pure::PureFile;
25use ryo_symbol::{
26    write_with_parents, CargoMetadataProvider, WorkspaceFilePath, WorkspaceMetadataProvider,
27    WorkspacePathResolver,
28};
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31
32/// Project error types
33#[derive(Debug, thiserror::Error)]
34pub enum ProjectError {
35    #[error("IO error: {0}")]
36    Io(#[from] std::io::Error),
37
38    #[error("Parse error in {path}: {message}")]
39    Parse { path: PathBuf, message: String },
40
41    #[error("File not found: {0}")]
42    FileNotFound(PathBuf),
43
44    #[error("Cargo metadata error: {0}")]
45    Metadata(#[from] ryo_symbol::MetadataError),
46
47    #[error("Config error: {0}")]
48    Config(#[from] crate::config::ConfigError),
49
50    #[error("Source generation failed: {0}")]
51    SourceGeneration(#[from] ryo_source::pure::ToSynError),
52}
53
54/// A project loaded entirely in memory as PureFile ASTs.
55///
56/// This is a pure data container with I/O capabilities.
57/// For transformations, use `ryo-executor::Workspace`.
58/// For searching, use `ryo-analysis::DiscoveryEngine`.
59///
60/// # Fields
61///
62/// - `config_root`: Location of `ryo.toml` (or where it would be)
63/// - `metadata`: Cargo workspace metadata (crate info, paths)
64/// - `config`: Project configuration from `ryo.toml`
65/// - `files`: Loaded source files
66pub struct Project {
67    /// Location of ryo.toml (or the directory passed to load)
68    config_root: PathBuf,
69
70    /// Cargo metadata provider (workspace info from cargo metadata)
71    metadata: CargoMetadataProvider,
72
73    /// Project configuration (from ryo.toml)
74    config: RyoConfig,
75
76    /// Files loaded as PureFile ASTs (keyed by absolute PathBuf for backward compat)
77    files: HashMap<PathBuf, PureFile>,
78}
79
80impl Project {
81    /// Load a project from disk
82    ///
83    /// # Workflow
84    ///
85    /// 1. Look for `ryo.toml` in the given directory
86    /// 2. Determine Cargo.toml location from config or auto-detect
87    /// 3. Create `CargoMetadataProvider` for workspace info
88    /// 4. Load files by traversing mod declarations from entry points
89    ///
90    /// # Arguments
91    ///
92    /// * `path` - Directory containing the project (with or without ryo.toml)
93    pub fn load(path: impl AsRef<Path>) -> Result<Self, ProjectError> {
94        let config_root = path.as_ref().canonicalize()?;
95
96        // 1. Load ryo.toml (or use default)
97        let config = RyoConfig::load_or_default(&config_root);
98
99        // 2. Determine manifest path
100        let manifest_path = Self::resolve_manifest_path(&config_root, &config);
101
102        // 3. Create CargoMetadataProvider
103        let metadata = CargoMetadataProvider::from_manifest(&manifest_path)?;
104
105        // 4. Load files by traversing mod declarations from entry points
106        let mut files = HashMap::new();
107        let workspace_root = metadata.workspace_root();
108        for member in metadata.members() {
109            Self::load_from_entry_points(workspace_root, member, &mut files)?;
110        }
111
112        Ok(Self {
113            config_root,
114            metadata,
115            config,
116            files,
117        })
118    }
119
120    /// Resolve the Cargo.toml manifest path
121    fn resolve_manifest_path(config_root: &Path, config: &RyoConfig) -> PathBuf {
122        // Priority:
123        // 1. Explicit manifest_path in config
124        // 2. workspace_root + Cargo.toml
125        // 3. config_root + Cargo.toml (no upward search if exists)
126        // 4. Search upward for Cargo.toml (only if not found in config_root)
127
128        if let Some(ref manifest) = config.project.manifest_path {
129            return config_root.join(manifest);
130        }
131
132        if let Some(ref ws_root) = config.project.workspace_root {
133            return config_root.join(ws_root).join("Cargo.toml");
134        }
135
136        // Check config_root first - if it exists, use it directly
137        let default_manifest = config_root.join("Cargo.toml");
138        if default_manifest.exists() {
139            return default_manifest;
140        }
141
142        // Only search upward if not found in config_root
143        config_root
144            .ancestors()
145            .skip(1) // Skip config_root itself (already checked)
146            .find(|p| p.join("Cargo.toml").exists())
147            .map(|p| p.join("Cargo.toml"))
148            .unwrap_or(default_manifest)
149    }
150
151    /// Get the config root (where ryo.toml is or would be)
152    pub fn config_root(&self) -> &Path {
153        &self.config_root
154    }
155
156    /// Get the workspace root (from Cargo metadata)
157    pub fn workspace_root(&self) -> &Path {
158        self.metadata.workspace_root()
159    }
160
161    /// Get the root path (alias for workspace_root for backward compatibility)
162    pub fn root(&self) -> &Path {
163        self.workspace_root()
164    }
165
166    /// Get the Cargo metadata provider
167    pub fn metadata(&self) -> &CargoMetadataProvider {
168        &self.metadata
169    }
170
171    /// Get the project configuration
172    pub fn config(&self) -> &RyoConfig {
173        &self.config
174    }
175
176    /// Create a WorkspacePathResolver for this project
177    ///
178    /// Uses workspace type from CargoMetadataProvider to correctly validate
179    /// `crate::` paths (ambiguous in multi-crate workspaces).
180    pub fn path_resolver(&self) -> WorkspacePathResolver {
181        WorkspacePathResolver::with_type(
182            self.workspace_root().to_path_buf(),
183            self.metadata.workspace_type(),
184        )
185    }
186
187    /// Get all file paths
188    pub fn file_paths(&self) -> impl Iterator<Item = &PathBuf> {
189        self.files.keys()
190    }
191
192    /// Get all files
193    pub fn files(&self) -> &HashMap<PathBuf, PureFile> {
194        &self.files
195    }
196
197    /// Get mutable access to files
198    pub fn files_mut(&mut self) -> &mut HashMap<PathBuf, PureFile> {
199        &mut self.files
200    }
201
202    /// Number of files loaded
203    pub fn file_count(&self) -> usize {
204        self.files.len()
205    }
206
207    /// Resolve a path to the actual key in the files HashMap.
208    /// Handles both relative and absolute paths.
209    pub fn resolve_path(&self, path: &Path) -> Option<PathBuf> {
210        // Try exact match first
211        if self.files.contains_key(path) {
212            return Some(path.to_path_buf());
213        }
214
215        // If relative path, try joining with root
216        if path.is_relative() {
217            let absolute = self.root().join(path);
218            if self.files.contains_key(&absolute) {
219                return Some(absolute);
220            }
221        }
222
223        // If absolute path, try canonicalizing (handles symlinks like /var -> /private/var)
224        if path.is_absolute() {
225            if let Ok(canonical) = path.canonicalize() {
226                if self.files.contains_key(&canonical) {
227                    return Some(canonical);
228                }
229            }
230            // Also try stripping root to get relative
231            if let Ok(relative) = path.strip_prefix(self.root()) {
232                let relative_buf = relative.to_path_buf();
233                if self.files.contains_key(&relative_buf) {
234                    return Some(relative_buf);
235                }
236            }
237        }
238
239        None
240    }
241
242    /// Get a file by path
243    pub fn get_file(&self, path: &Path) -> Option<&PureFile> {
244        self.resolve_path(path)
245            .and_then(|resolved| self.files.get(&resolved))
246    }
247
248    /// Get a mutable file by path
249    pub fn get_file_mut(&mut self, path: &Path) -> Option<&mut PureFile> {
250        if let Some(resolved) = self.resolve_path(path) {
251            self.files.get_mut(&resolved)
252        } else {
253            None
254        }
255    }
256
257    /// Insert or update a file
258    pub fn insert_file(&mut self, path: PathBuf, file: PureFile) {
259        self.files.insert(path, file);
260    }
261
262    /// Get a file and its resolved path
263    pub fn get_file_with_path(&self, path: &Path) -> Option<(PathBuf, &PureFile)> {
264        self.resolve_path(path)
265            .and_then(|resolved| self.files.get(&resolved).map(|f| (resolved, f)))
266    }
267
268    /// Get a mutable file and its resolved path
269    pub fn get_file_mut_with_path(&mut self, path: &Path) -> Option<(PathBuf, &mut PureFile)> {
270        if let Some(resolved) = self.resolve_path(path) {
271            self.files.get_mut(&resolved).map(|f| (resolved, f))
272        } else {
273            None
274        }
275    }
276
277    /// Check if a file exists
278    pub fn contains_file(&self, path: &Path) -> bool {
279        self.resolve_path(path).is_some()
280    }
281
282    /// Get generated source for a file
283    pub fn get_source(&self, path: &Path) -> Result<Option<String>, ProjectError> {
284        Ok(self.get_file(path).map(|f| f.to_source()).transpose()?)
285    }
286
287    /// Write modified files back to disk
288    ///
289    /// Creates parent directories if they don't exist (for newly created files).
290    pub fn write_to_disk(&self, paths: &[PathBuf]) -> Result<usize, ProjectError> {
291        let mut written = 0;
292
293        for path in paths {
294            if let Some(file) = self.files.get(path) {
295                let source = file.to_source()?;
296                write_with_parents(path, &source)?;
297                written += 1;
298            }
299        }
300
301        Ok(written)
302    }
303
304    /// Write all files to disk
305    pub fn write_all_to_disk(&self) -> Result<usize, ProjectError> {
306        let paths: Vec<_> = self.files.keys().cloned().collect();
307        self.write_to_disk(&paths)
308    }
309
310    // ========================================================================
311    // Context-Centric I/O (Phase 5)
312    // ========================================================================
313
314    /// Load files from a project directory without creating a Project instance.
315    ///
316    /// This is the preferred method for the Context-centric design where
317    /// `AnalysisContext` is the Single Source of Truth for file content.
318    ///
319    /// # Example
320    ///
321    /// ```ignore
322    /// let files = Project::load_files("/path/to/project")?;
323    /// let context = AnalysisContext::from_path_files(files, "crate");
324    /// ```
325    pub fn load_files(root: impl AsRef<Path>) -> Result<HashMap<PathBuf, PureFile>, ProjectError> {
326        let root = root.as_ref().canonicalize()?;
327        let mut files = HashMap::new();
328
329        Self::load_dir(&root, &root, &mut files)?;
330
331        Ok(files)
332    }
333
334    /// Write modified files from an AnalysisContext to disk.
335    ///
336    /// # Arguments
337    ///
338    /// * `ctx` - The AnalysisContext containing the modified files
339    /// * `files` - The WorkspaceFilePaths of the files to write
340    ///
341    /// # Returns
342    ///
343    /// The number of files successfully written to disk.
344    pub fn write_from_context(
345        &self,
346        ctx: &ryo_analysis::AnalysisContext,
347        files: &[WorkspaceFilePath],
348    ) -> Result<usize, ProjectError> {
349        let mut written = 0;
350
351        for file_path in files {
352            if let Some(file) = ctx.file(file_path) {
353                let source = file.to_source()?;
354                // Use WorkspaceFilePath::write() to ensure parent dirs are created
355                file_path.write(&source)?;
356                written += 1;
357            }
358        }
359
360        Ok(written)
361    }
362
363    /// Sync modified files from Context back to Project.files.
364    ///
365    /// Maintains backward compatibility for code that still reads from Project.files.
366    pub fn sync_from_context(
367        &mut self,
368        ctx: &ryo_analysis::AnalysisContext,
369        files: &[WorkspaceFilePath],
370    ) {
371        for file_path in files {
372            if let Some(file) = ctx.file(file_path) {
373                let absolute_path = file_path.to_absolute();
374                self.files.insert(absolute_path, (*file).clone());
375            }
376        }
377    }
378
379    // ========================================================================
380    // Private helpers
381    // ========================================================================
382
383    /// Load files from a crate's entry points by traversing mod declarations
384    fn load_from_entry_points(
385        workspace_root: &Path,
386        crate_info: &ryo_symbol::CrateInfo,
387        files: &mut HashMap<PathBuf, PureFile>,
388    ) -> Result<(), ProjectError> {
389        use ryo_symbol::TargetKind;
390
391        for target in &crate_info.entry_points {
392            // Only process lib and bin targets (skip tests, examples, benches)
393            if !matches!(target.kind, TargetKind::Lib | TargetKind::Bin) {
394                continue;
395            }
396
397            // target.src_path is relative to workspace_root
398            let entry_path = workspace_root.join(target.src_path.as_str());
399            if entry_path.exists() {
400                Self::load_module_tree(&entry_path, files)?;
401            }
402        }
403
404        Ok(())
405    }
406
407    /// Recursively load a module and all its child modules
408    fn load_module_tree(
409        file_path: &Path,
410        files: &mut HashMap<PathBuf, PureFile>,
411    ) -> Result<(), ProjectError> {
412        // Skip if already loaded
413        if files.contains_key(file_path) {
414            return Ok(());
415        }
416
417        // Try to canonicalize for consistent keys
418        let canonical_path = file_path
419            .canonicalize()
420            .unwrap_or_else(|_| file_path.to_path_buf());
421
422        if files.contains_key(&canonical_path) {
423            return Ok(());
424        }
425
426        // Load and parse the file
427        let pure_file = match Self::load_file(file_path) {
428            Ok(f) => f,
429            Err(e) => {
430                tracing::warn!("Failed to load {}: {}", file_path.display(), e);
431                return Ok(());
432            }
433        };
434
435        // Extract mod declarations before inserting
436        let mod_names: Vec<String> = pure_file
437            .items
438            .iter()
439            .filter_map(|item| {
440                if let ryo_source::pure::PureItem::Mod(m) = item {
441                    // Only external mod declarations (mod foo;)
442                    if m.items.is_empty() {
443                        return Some(m.name.clone());
444                    }
445                }
446                None
447            })
448            .collect();
449
450        // Insert the file
451        files.insert(canonical_path.clone(), pure_file);
452
453        // Get the directory for child modules.
454        // For Rust 2018 style (src/foo.rs), child modules are in src/foo/
455        // For classic style (src/foo/mod.rs), child modules are in src/foo/
456        let parent_dir = canonical_path
457            .parent()
458            .ok_or_else(|| ProjectError::FileNotFound(file_path.to_path_buf()))?;
459
460        // Determine the search directory for child modules
461        let child_search_dir = if let Some(file_stem) = canonical_path.file_stem() {
462            let file_name = canonical_path.file_name().and_then(|n| n.to_str());
463            if file_name != Some("mod.rs")
464                && file_name != Some("lib.rs")
465                && file_name != Some("main.rs")
466            {
467                // Rust 2018 style: src/foo.rs -> child modules in src/foo/
468                parent_dir.join(file_stem)
469            } else {
470                // Classic style: src/foo/mod.rs -> child modules in src/foo/
471                parent_dir.to_path_buf()
472            }
473        } else {
474            parent_dir.to_path_buf()
475        };
476
477        // Resolve and load child modules
478        for mod_name in mod_names {
479            if let Some(child_path) = Self::resolve_mod_path(&child_search_dir, &mod_name) {
480                Self::load_module_tree(&child_path, files)?;
481            }
482        }
483
484        Ok(())
485    }
486
487    /// Resolve a module name to its file path
488    ///
489    /// Follows Rust's module resolution rules:
490    /// - `mod foo;` in `src/lib.rs` → `src/foo.rs` or `src/foo/mod.rs`
491    /// - `mod bar;` in `src/foo/mod.rs` → `src/foo/bar.rs` or `src/foo/bar/mod.rs`
492    fn resolve_mod_path(parent_dir: &Path, mod_name: &str) -> Option<PathBuf> {
493        // Try modern style: parent/mod_name.rs
494        let modern_path = parent_dir.join(format!("{}.rs", mod_name));
495        if modern_path.exists() {
496            return Some(modern_path);
497        }
498
499        // Try classic style: parent/mod_name/mod.rs
500        let classic_path = parent_dir.join(mod_name).join("mod.rs");
501        if classic_path.exists() {
502            return Some(classic_path);
503        }
504
505        // Module file not found (could be in #[path = "..."] attribute)
506        tracing::debug!(
507            "Module '{}' not found in {} (tried {} and {})",
508            mod_name,
509            parent_dir.display(),
510            modern_path.display(),
511            classic_path.display()
512        );
513        None
514    }
515
516    #[allow(dead_code)]
517    fn load_dir(
518        _root: &Path,
519        dir: &Path,
520        files: &mut HashMap<PathBuf, PureFile>,
521    ) -> Result<(), ProjectError> {
522        if !dir.is_dir() {
523            return Ok(());
524        }
525
526        let dir_name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
527        if matches!(
528            dir_name,
529            "target" | "node_modules" | ".git" | "dist" | "build"
530        ) {
531            return Ok(());
532        }
533
534        for entry in std::fs::read_dir(dir)? {
535            let entry = entry?;
536            let path = entry.path();
537
538            if path.is_dir() {
539                Self::load_dir(_root, &path, files)?;
540            } else if path.extension().map(|e| e == "rs").unwrap_or(false) {
541                match Self::load_file(&path) {
542                    Ok(pure) => {
543                        files.insert(path, pure);
544                    }
545                    Err(e) => {
546                        tracing::warn!("Failed to parse {}: {}", path.display(), e);
547                    }
548                }
549            }
550        }
551
552        Ok(())
553    }
554
555    fn load_file(path: &Path) -> Result<PureFile, ProjectError> {
556        let content = std::fs::read_to_string(path)?;
557        PureFile::from_source(&content).map_err(|e| ProjectError::Parse {
558            path: path.to_path_buf(),
559            message: e.to_string(),
560        })
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use std::fs;
568    use tempfile::tempdir;
569
570    fn create_test_project() -> tempfile::TempDir {
571        let dir = tempdir().unwrap();
572        let src = dir.path().join("src");
573        fs::create_dir(&src).unwrap();
574
575        // Create Cargo.toml (required for CargoMetadataProvider)
576        fs::write(
577            dir.path().join("Cargo.toml"),
578            r#"[package]
579name = "test-project"
580version = "0.1.0"
581edition = "2021"
582"#,
583        )
584        .unwrap();
585
586        fs::write(
587            src.join("lib.rs"),
588            r#"
589pub fn hello() -> &'static str {
590    "Hello, World!"
591}
592"#,
593        )
594        .unwrap();
595
596        fs::write(
597            src.join("main.rs"),
598            r#"
599fn main() {
600    println!("{}", hello());
601}
602"#,
603        )
604        .unwrap();
605
606        dir
607    }
608
609    #[test]
610    fn test_load_project() {
611        let dir = create_test_project();
612        let project = Project::load(dir.path()).unwrap();
613
614        // Project successfully loaded
615        assert!(
616            project.file_count() >= 1,
617            "Expected at least 1 file, got {}",
618            project.file_count()
619        );
620        assert!(project.workspace_root().exists());
621
622        // Verify lib.rs is present (bin targets may not be loaded depending on cargo metadata)
623        let lib_exists = project.file_paths().any(|p| p.ends_with("lib.rs"));
624        assert!(lib_exists, "lib.rs not found in project");
625    }
626
627    #[test]
628    fn test_get_file() {
629        let dir = create_test_project();
630        let project = Project::load(dir.path()).unwrap();
631
632        // Use canonicalized path to handle macOS /var -> /private/var symlink
633        let lib_path = dir.path().canonicalize().unwrap().join("src/lib.rs");
634        assert!(project.get_file(&lib_path).is_some());
635    }
636
637    #[test]
638    fn test_resolve_relative_path() {
639        let dir = create_test_project();
640        let project = Project::load(dir.path()).unwrap();
641
642        // Should resolve relative path
643        let relative = PathBuf::from("src/lib.rs");
644        let resolved = project.resolve_path(&relative);
645        assert!(resolved.is_some(), "Failed to resolve src/lib.rs");
646
647        // Verify the resolved path exists in files
648        if let Some(resolved_path) = resolved {
649            assert!(
650                project.files().contains_key(&resolved_path),
651                "Resolved path not in files: {:?}",
652                resolved_path
653            );
654        }
655    }
656
657    #[test]
658    fn test_metadata_provider() {
659        let dir = create_test_project();
660        let project = Project::load(dir.path()).unwrap();
661
662        // Check that metadata provider is available
663        let metadata = project.metadata();
664        assert_eq!(metadata.workspace_root(), project.workspace_root());
665
666        // Check crate info
667        let crates = metadata.all_crates();
668        assert!(!crates.is_empty());
669    }
670
671    #[test]
672    fn test_path_resolver() {
673        let dir = create_test_project();
674        let project = Project::load(dir.path()).unwrap();
675
676        // Check that path resolver works
677        let resolver = project.path_resolver();
678        // Use canonicalized path to handle macOS /var -> /private/var symlink
679        let absolute_path = dir.path().canonicalize().unwrap().join("src/lib.rs");
680        let result = resolver.resolve(&absolute_path);
681        assert!(result.is_ok());
682    }
683
684    #[test]
685    fn test_load_files_static() {
686        // Test that load_files() returns files without creating a Project instance
687        let dir = create_test_project();
688        let files = Project::load_files(dir.path()).unwrap();
689
690        assert_eq!(files.len(), 2);
691        assert!(files
692            .values()
693            .any(|f| f.to_source().unwrap().contains("hello")));
694    }
695
696    // Note: Context integration tests removed - they depend on old FileId-based API.
697    // New tests should use WorkspaceFilePath-based API with AnalysisContext.files().
698}