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    #[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    /// Find which workspace a file belongs to, if any.
67    #[must_use]
68    pub fn workspace_for_file(&self, id: FileId) -> Option<&WorkspaceInfo> {
69        let path = &self.files.get(id.0 as usize)?.path;
70        self.workspaces.iter().find(|ws| path.starts_with(&ws.root))
71    }
72
73    /// Look up a workspace by package name.
74    #[must_use]
75    pub fn workspace_by_name(&self, name: &str) -> Option<&WorkspaceInfo> {
76        self.workspaces.iter().find(|ws| ws.name == name)
77    }
78
79    /// Get all `FileId`s for files within a workspace.
80    #[must_use]
81    pub fn files_in_workspace(&self, ws: &WorkspaceInfo) -> Vec<FileId> {
82        self.files
83            .iter()
84            .filter(|f| f.path.starts_with(&ws.root))
85            .map(|f| f.id)
86            .collect()
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    fn make_file(id: u32, path: &str) -> DiscoveredFile {
95        DiscoveredFile {
96            id: FileId(id),
97            path: PathBuf::from(path),
98            size_bytes: 100,
99        }
100    }
101
102    fn make_workspace(name: &str, root: &str) -> WorkspaceInfo {
103        WorkspaceInfo {
104            root: PathBuf::from(root),
105            name: name.to_string(),
106            is_internal_dependency: false,
107        }
108    }
109
110    #[test]
111    fn id_for_path_lookup() {
112        let files = vec![
113            make_file(0, "/project/packages/a/src/index.ts"),
114            make_file(1, "/project/packages/b/src/index.ts"),
115        ];
116        let state = ProjectState::new(files, vec![]);
117        assert_eq!(
118            state.id_for_path(Path::new("/project/packages/a/src/index.ts")),
119            Some(FileId(0))
120        );
121        assert_eq!(
122            state.id_for_path(Path::new("/project/packages/b/src/index.ts")),
123            Some(FileId(1))
124        );
125        assert_eq!(state.id_for_path(Path::new("/project/missing.ts")), None);
126    }
127
128    #[test]
129    fn workspace_for_file_lookup() {
130        let files = vec![
131            make_file(0, "/project/packages/ui/src/button.ts"),
132            make_file(1, "/project/src/app.ts"),
133        ];
134        let workspaces = vec![make_workspace("ui", "/project/packages/ui")];
135        let state = ProjectState::new(files, workspaces);
136
137        assert_eq!(
138            state.workspace_for_file(FileId(0)).map(|ws| &ws.name),
139            Some(&"ui".to_string())
140        );
141        assert!(state.workspace_for_file(FileId(1)).is_none());
142    }
143
144    #[test]
145    fn workspace_by_name_lookup() {
146        let workspaces = vec![
147            make_workspace("ui", "/project/packages/ui"),
148            make_workspace("core", "/project/packages/core"),
149        ];
150        let state = ProjectState::new(vec![], workspaces);
151
152        assert!(state.workspace_by_name("ui").is_some());
153        assert!(state.workspace_by_name("core").is_some());
154        assert!(state.workspace_by_name("missing").is_none());
155    }
156
157    #[test]
158    fn files_in_workspace() {
159        let files = vec![
160            make_file(0, "/project/packages/ui/src/a.ts"),
161            make_file(1, "/project/packages/ui/src/b.ts"),
162            make_file(2, "/project/packages/core/src/c.ts"),
163            make_file(3, "/project/src/app.ts"),
164        ];
165        let workspaces = vec![
166            make_workspace("ui", "/project/packages/ui"),
167            make_workspace("core", "/project/packages/core"),
168        ];
169        let state = ProjectState::new(files, workspaces);
170
171        let ui_ws = state.workspace_by_name("ui").unwrap();
172        let ui_files = state.files_in_workspace(ui_ws);
173        assert_eq!(ui_files, vec![FileId(0), FileId(1)]);
174
175        let core_ws = state.workspace_by_name("core").unwrap();
176        let core_files = state.files_in_workspace(core_ws);
177        assert_eq!(core_files, vec![FileId(2)]);
178    }
179
180    #[test]
181    fn file_by_id_valid() {
182        let files = vec![
183            make_file(0, "/project/src/a.ts"),
184            make_file(1, "/project/src/b.ts"),
185        ];
186        let state = ProjectState::new(files, vec![]);
187        let file = state.file_by_id(FileId(0)).unwrap();
188        assert_eq!(file.path, PathBuf::from("/project/src/a.ts"));
189        assert_eq!(file.id, FileId(0));
190    }
191
192    #[test]
193    fn file_by_id_out_of_bounds() {
194        let files = vec![make_file(0, "/project/src/a.ts")];
195        let state = ProjectState::new(files, vec![]);
196        assert!(state.file_by_id(FileId(999)).is_none());
197    }
198
199    #[test]
200    fn workspace_for_file_out_of_bounds() {
201        let files = vec![make_file(0, "/project/src/a.ts")];
202        let workspaces = vec![make_workspace("app", "/project")];
203        let state = ProjectState::new(files, workspaces);
204        assert!(state.workspace_for_file(FileId(999)).is_none());
205    }
206
207    #[test]
208    fn empty_state() {
209        let state = ProjectState::new(vec![], vec![]);
210        assert!(state.files().is_empty());
211        assert!(state.workspaces().is_empty());
212        assert!(state.file_by_id(FileId(0)).is_none());
213        assert!(state.id_for_path(Path::new("/any")).is_none());
214        assert!(state.workspace_by_name("any").is_none());
215    }
216
217    #[test]
218    fn files_returns_all_files() {
219        let files = vec![
220            make_file(0, "/project/src/a.ts"),
221            make_file(1, "/project/src/b.ts"),
222        ];
223        let state = ProjectState::new(files, vec![]);
224        assert_eq!(state.files().len(), 2);
225        assert_eq!(state.files()[0].id, FileId(0));
226        assert_eq!(state.files()[1].id, FileId(1));
227    }
228
229    #[test]
230    fn workspaces_returns_all_workspaces() {
231        let workspaces = vec![
232            make_workspace("a", "/project/packages/a"),
233            make_workspace("b", "/project/packages/b"),
234        ];
235        let state = ProjectState::new(vec![], workspaces);
236        assert_eq!(state.workspaces().len(), 2);
237    }
238
239    #[test]
240    fn files_in_workspace_empty_when_no_match() {
241        let files = vec![make_file(0, "/other/path/file.ts")];
242        let workspaces = vec![make_workspace("ui", "/project/packages/ui")];
243        let state = ProjectState::new(files, workspaces);
244        let ws = state.workspace_by_name("ui").unwrap();
245        assert!(state.files_in_workspace(ws).is_empty());
246    }
247
248    #[test]
249    fn workspace_for_file_nested_workspaces() {
250        // When a file could match multiple workspaces, the first match wins.
251        // This tests the behavior with nested workspace roots.
252        let files = vec![make_file(0, "/project/packages/ui/components/Button.ts")];
253        let workspaces = vec![
254            make_workspace("root", "/project"),
255            make_workspace("ui", "/project/packages/ui"),
256        ];
257        let state = ProjectState::new(files, workspaces);
258        // Both workspaces match, but find() returns the first one
259        let ws = state.workspace_for_file(FileId(0)).unwrap();
260        assert_eq!(ws.name, "root");
261    }
262}