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, StableFileKey};
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    #[must_use]
29    pub fn new(files: Vec<DiscoveredFile>, workspaces: Vec<WorkspaceInfo>) -> Self {
30        debug_assert!(
31            files.iter().enumerate().all(|(i, f)| f.id.0 as usize == i),
32            "FileIds must be densely packed starting at 0"
33        );
34        let path_to_id = files.iter().map(|f| (f.path.clone(), f.id)).collect();
35        Self {
36            files,
37            path_to_id,
38            workspaces,
39        }
40    }
41
42    /// All discovered files, indexed by `FileId`.
43    #[must_use]
44    pub fn files(&self) -> &[DiscoveredFile] {
45        &self.files
46    }
47
48    /// All discovered workspace packages.
49    #[must_use]
50    pub fn workspaces(&self) -> &[WorkspaceInfo] {
51        &self.workspaces
52    }
53
54    /// Look up a file by its `FileId`.
55    #[must_use]
56    pub fn file_by_id(&self, id: FileId) -> Option<&DiscoveredFile> {
57        self.files.get(id.0 as usize)
58    }
59
60    /// Look up a `FileId` by absolute path.
61    #[must_use]
62    pub fn id_for_path(&self, path: &Path) -> Option<FileId> {
63        self.path_to_id.get(path).copied()
64    }
65
66    /// Stable file key for a `FileId`, root-relative where possible.
67    #[must_use]
68    pub fn stable_key_for_file(&self, root: &Path, id: FileId) -> Option<StableFileKey> {
69        let file = self.file_by_id(id)?;
70        Some(StableFileKey::from_root_relative(root, &file.path))
71    }
72
73    /// Look up the current in-memory `FileId` for a stable root-relative key.
74    #[must_use]
75    pub fn id_for_stable_key(&self, root: &Path, key: &StableFileKey) -> Option<FileId> {
76        self.files
77            .iter()
78            .find(|file| StableFileKey::from_root_relative(root, &file.path) == *key)
79            .map(|file| file.id)
80    }
81
82    /// Find which workspace a file belongs to, if any.
83    #[must_use]
84    pub fn workspace_for_file(&self, id: FileId) -> Option<&WorkspaceInfo> {
85        let path = &self.files.get(id.0 as usize)?.path;
86        self.workspaces.iter().find(|ws| path.starts_with(&ws.root))
87    }
88
89    /// Look up a workspace by package name.
90    #[must_use]
91    pub fn workspace_by_name(&self, name: &str) -> Option<&WorkspaceInfo> {
92        self.workspaces.iter().find(|ws| ws.name == name)
93    }
94
95    /// Get all `FileId`s for files within a workspace.
96    #[must_use]
97    pub fn files_in_workspace(&self, ws: &WorkspaceInfo) -> Vec<FileId> {
98        self.files
99            .iter()
100            .filter(|f| f.path.starts_with(&ws.root))
101            .map(|f| f.id)
102            .collect()
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn make_file(id: u32, path: &str) -> DiscoveredFile {
111        DiscoveredFile {
112            id: FileId(id),
113            path: PathBuf::from(path),
114            size_bytes: 100,
115        }
116    }
117
118    fn make_workspace(name: &str, root: &str) -> WorkspaceInfo {
119        WorkspaceInfo {
120            root: PathBuf::from(root),
121            name: name.to_string(),
122            is_internal_dependency: false,
123        }
124    }
125
126    #[test]
127    fn id_for_path_lookup() {
128        let files = vec![
129            make_file(0, "/project/packages/a/src/index.ts"),
130            make_file(1, "/project/packages/b/src/index.ts"),
131        ];
132        let state = ProjectState::new(files, vec![]);
133        assert_eq!(
134            state.id_for_path(Path::new("/project/packages/a/src/index.ts")),
135            Some(FileId(0))
136        );
137        assert_eq!(
138            state.id_for_path(Path::new("/project/packages/b/src/index.ts")),
139            Some(FileId(1))
140        );
141        assert_eq!(state.id_for_path(Path::new("/project/missing.ts")), None);
142    }
143
144    #[test]
145    fn stable_key_for_file_is_root_relative() {
146        let files = vec![make_file(0, "/project/src/a.ts")];
147        let state = ProjectState::new(files, vec![]);
148
149        let key = state
150            .stable_key_for_file(Path::new("/project"), FileId(0))
151            .unwrap();
152
153        assert_eq!(key.as_str(), "src/a.ts");
154        assert_eq!(
155            state.id_for_stable_key(Path::new("/project"), &key),
156            Some(FileId(0))
157        );
158    }
159
160    #[test]
161    fn stable_key_survives_file_id_shift() {
162        let before = ProjectState::new(
163            vec![
164                make_file(0, "/project/src/a.ts"),
165                make_file(1, "/project/src/c.ts"),
166            ],
167            vec![],
168        );
169        let after = ProjectState::new(
170            vec![
171                make_file(0, "/project/src/a.ts"),
172                make_file(1, "/project/src/b.ts"),
173                make_file(2, "/project/src/c.ts"),
174            ],
175            vec![],
176        );
177        let root = Path::new("/project");
178        let key = before.stable_key_for_file(root, FileId(1)).unwrap();
179
180        assert_eq!(key.as_str(), "src/c.ts");
181        assert_eq!(before.id_for_stable_key(root, &key), Some(FileId(1)));
182        assert_eq!(after.id_for_stable_key(root, &key), Some(FileId(2)));
183    }
184
185    #[test]
186    fn workspace_for_file_lookup() {
187        let files = vec![
188            make_file(0, "/project/packages/ui/src/button.ts"),
189            make_file(1, "/project/src/app.ts"),
190        ];
191        let workspaces = vec![make_workspace("ui", "/project/packages/ui")];
192        let state = ProjectState::new(files, workspaces);
193
194        assert_eq!(
195            state.workspace_for_file(FileId(0)).map(|ws| &ws.name),
196            Some(&"ui".to_string())
197        );
198        assert!(state.workspace_for_file(FileId(1)).is_none());
199    }
200
201    #[test]
202    fn workspace_by_name_lookup() {
203        let workspaces = vec![
204            make_workspace("ui", "/project/packages/ui"),
205            make_workspace("core", "/project/packages/core"),
206        ];
207        let state = ProjectState::new(vec![], workspaces);
208
209        assert!(state.workspace_by_name("ui").is_some());
210        assert!(state.workspace_by_name("core").is_some());
211        assert!(state.workspace_by_name("missing").is_none());
212    }
213
214    #[test]
215    fn files_in_workspace() {
216        let files = vec![
217            make_file(0, "/project/packages/ui/src/a.ts"),
218            make_file(1, "/project/packages/ui/src/b.ts"),
219            make_file(2, "/project/packages/core/src/c.ts"),
220            make_file(3, "/project/src/app.ts"),
221        ];
222        let workspaces = vec![
223            make_workspace("ui", "/project/packages/ui"),
224            make_workspace("core", "/project/packages/core"),
225        ];
226        let state = ProjectState::new(files, workspaces);
227
228        let ui_ws = state.workspace_by_name("ui").unwrap();
229        let ui_files = state.files_in_workspace(ui_ws);
230        assert_eq!(ui_files, vec![FileId(0), FileId(1)]);
231
232        let core_ws = state.workspace_by_name("core").unwrap();
233        let core_files = state.files_in_workspace(core_ws);
234        assert_eq!(core_files, vec![FileId(2)]);
235    }
236
237    #[test]
238    fn file_by_id_valid() {
239        let files = vec![
240            make_file(0, "/project/src/a.ts"),
241            make_file(1, "/project/src/b.ts"),
242        ];
243        let state = ProjectState::new(files, vec![]);
244        let file = state.file_by_id(FileId(0)).unwrap();
245        assert_eq!(file.path, PathBuf::from("/project/src/a.ts"));
246        assert_eq!(file.id, FileId(0));
247    }
248
249    #[test]
250    fn file_by_id_out_of_bounds() {
251        let files = vec![make_file(0, "/project/src/a.ts")];
252        let state = ProjectState::new(files, vec![]);
253        assert!(state.file_by_id(FileId(999)).is_none());
254    }
255
256    #[test]
257    fn workspace_for_file_out_of_bounds() {
258        let files = vec![make_file(0, "/project/src/a.ts")];
259        let workspaces = vec![make_workspace("app", "/project")];
260        let state = ProjectState::new(files, workspaces);
261        assert!(state.workspace_for_file(FileId(999)).is_none());
262    }
263
264    #[test]
265    fn empty_state() {
266        let state = ProjectState::new(vec![], vec![]);
267        assert!(state.files().is_empty());
268        assert!(state.workspaces().is_empty());
269        assert!(state.file_by_id(FileId(0)).is_none());
270        assert!(state.id_for_path(Path::new("/any")).is_none());
271        assert!(state.workspace_by_name("any").is_none());
272    }
273
274    #[test]
275    fn files_returns_all_files() {
276        let files = vec![
277            make_file(0, "/project/src/a.ts"),
278            make_file(1, "/project/src/b.ts"),
279        ];
280        let state = ProjectState::new(files, vec![]);
281        assert_eq!(state.files().len(), 2);
282        assert_eq!(state.files()[0].id, FileId(0));
283        assert_eq!(state.files()[1].id, FileId(1));
284    }
285
286    #[test]
287    fn workspaces_returns_all_workspaces() {
288        let workspaces = vec![
289            make_workspace("a", "/project/packages/a"),
290            make_workspace("b", "/project/packages/b"),
291        ];
292        let state = ProjectState::new(vec![], workspaces);
293        assert_eq!(state.workspaces().len(), 2);
294    }
295
296    #[test]
297    fn files_in_workspace_empty_when_no_match() {
298        let files = vec![make_file(0, "/other/path/file.ts")];
299        let workspaces = vec![make_workspace("ui", "/project/packages/ui")];
300        let state = ProjectState::new(files, workspaces);
301        let ws = state.workspace_by_name("ui").unwrap();
302        assert!(state.files_in_workspace(ws).is_empty());
303    }
304
305    #[test]
306    fn workspace_for_file_nested_workspaces() {
307        let files = vec![make_file(0, "/project/packages/ui/components/Button.ts")];
308        let workspaces = vec![
309            make_workspace("root", "/project"),
310            make_workspace("ui", "/project/packages/ui"),
311        ];
312        let state = ProjectState::new(files, workspaces);
313        let ws = state.workspace_for_file(FileId(0)).unwrap();
314        assert_eq!(ws.name, "root");
315    }
316}