Skip to main content

fallow_graph/
project.rs

1//! Centralized project state with file registry and workspace metadata.
2
3use std::path::{Path, PathBuf};
4
5use rustc_hash::FxHashMap;
6
7use fallow_config::WorkspaceInfo;
8
9use fallow_types::discover::{DiscoveredFile, FileId};
10
11/// Centralized project state owning the file registry and workspace metadata.
12///
13/// Provides:
14/// - Stable `FileId` assignment (deterministic by path, not by size)
15/// - O(1) path-to-id lookups for cross-workspace module resolution
16/// - Workspace-aware queries (which workspace owns a file, files in a workspace)
17///
18/// Future incremental analysis will persist the id assignment across runs so
19/// that adding/removing files does not invalidate cached graph data.
20pub struct ProjectState {
21    files: Vec<DiscoveredFile>,
22    path_to_id: FxHashMap<PathBuf, FileId>,
23    workspaces: Vec<WorkspaceInfo>,
24}
25
26impl ProjectState {
27    /// Build a new project state from discovered files and workspaces.
28    pub fn new(files: Vec<DiscoveredFile>, workspaces: Vec<WorkspaceInfo>) -> Self {
29        let path_to_id = files.iter().map(|f| (f.path.clone(), f.id)).collect();
30        Self {
31            files,
32            path_to_id,
33            workspaces,
34        }
35    }
36
37    /// All discovered files, indexed by `FileId`.
38    pub fn files(&self) -> &[DiscoveredFile] {
39        &self.files
40    }
41
42    /// All discovered workspace packages.
43    pub fn workspaces(&self) -> &[WorkspaceInfo] {
44        &self.workspaces
45    }
46
47    /// Look up a file by its `FileId`.
48    pub fn file_by_id(&self, id: FileId) -> Option<&DiscoveredFile> {
49        self.files.get(id.0 as usize)
50    }
51
52    /// Look up a `FileId` by absolute path.
53    pub fn id_for_path(&self, path: &Path) -> Option<FileId> {
54        self.path_to_id.get(path).copied()
55    }
56
57    /// Find which workspace a file belongs to, if any.
58    pub fn workspace_for_file(&self, id: FileId) -> Option<&WorkspaceInfo> {
59        let path = &self.files.get(id.0 as usize)?.path;
60        self.workspaces.iter().find(|ws| path.starts_with(&ws.root))
61    }
62
63    /// Look up a workspace by package name.
64    pub fn workspace_by_name(&self, name: &str) -> Option<&WorkspaceInfo> {
65        self.workspaces.iter().find(|ws| ws.name == name)
66    }
67
68    /// Get all `FileId`s for files within a workspace.
69    pub fn files_in_workspace(&self, ws: &WorkspaceInfo) -> Vec<FileId> {
70        self.files
71            .iter()
72            .filter(|f| f.path.starts_with(&ws.root))
73            .map(|f| f.id)
74            .collect()
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    fn make_file(id: u32, path: &str) -> DiscoveredFile {
83        DiscoveredFile {
84            id: FileId(id),
85            path: PathBuf::from(path),
86            size_bytes: 100,
87        }
88    }
89
90    fn make_workspace(name: &str, root: &str) -> WorkspaceInfo {
91        WorkspaceInfo {
92            root: PathBuf::from(root),
93            name: name.to_string(),
94            is_internal_dependency: false,
95        }
96    }
97
98    #[test]
99    fn id_for_path_lookup() {
100        let files = vec![
101            make_file(0, "/project/packages/a/src/index.ts"),
102            make_file(1, "/project/packages/b/src/index.ts"),
103        ];
104        let state = ProjectState::new(files, vec![]);
105        assert_eq!(
106            state.id_for_path(Path::new("/project/packages/a/src/index.ts")),
107            Some(FileId(0))
108        );
109        assert_eq!(
110            state.id_for_path(Path::new("/project/packages/b/src/index.ts")),
111            Some(FileId(1))
112        );
113        assert_eq!(state.id_for_path(Path::new("/project/missing.ts")), None);
114    }
115
116    #[test]
117    fn workspace_for_file_lookup() {
118        let files = vec![
119            make_file(0, "/project/packages/ui/src/button.ts"),
120            make_file(1, "/project/src/app.ts"),
121        ];
122        let workspaces = vec![make_workspace("ui", "/project/packages/ui")];
123        let state = ProjectState::new(files, workspaces);
124
125        assert_eq!(
126            state.workspace_for_file(FileId(0)).map(|ws| &ws.name),
127            Some(&"ui".to_string())
128        );
129        assert!(state.workspace_for_file(FileId(1)).is_none());
130    }
131
132    #[test]
133    fn workspace_by_name_lookup() {
134        let workspaces = vec![
135            make_workspace("ui", "/project/packages/ui"),
136            make_workspace("core", "/project/packages/core"),
137        ];
138        let state = ProjectState::new(vec![], workspaces);
139
140        assert!(state.workspace_by_name("ui").is_some());
141        assert!(state.workspace_by_name("core").is_some());
142        assert!(state.workspace_by_name("missing").is_none());
143    }
144
145    #[test]
146    fn files_in_workspace() {
147        let files = vec![
148            make_file(0, "/project/packages/ui/src/a.ts"),
149            make_file(1, "/project/packages/ui/src/b.ts"),
150            make_file(2, "/project/packages/core/src/c.ts"),
151            make_file(3, "/project/src/app.ts"),
152        ];
153        let workspaces = vec![
154            make_workspace("ui", "/project/packages/ui"),
155            make_workspace("core", "/project/packages/core"),
156        ];
157        let state = ProjectState::new(files, workspaces);
158
159        let ui_ws = state.workspace_by_name("ui").unwrap();
160        let ui_files = state.files_in_workspace(ui_ws);
161        assert_eq!(ui_files, vec![FileId(0), FileId(1)]);
162
163        let core_ws = state.workspace_by_name("core").unwrap();
164        let core_files = state.files_in_workspace(core_ws);
165        assert_eq!(core_files, vec![FileId(2)]);
166    }
167}