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        debug_assert!(
30            files.iter().enumerate().all(|(i, f)| f.id.0 as usize == i),
31            "FileIds must be densely packed starting at 0"
32        );
33        let path_to_id = files.iter().map(|f| (f.path.clone(), f.id)).collect();
34        Self {
35            files,
36            path_to_id,
37            workspaces,
38        }
39    }
40
41    /// All discovered files, indexed by `FileId`.
42    pub fn files(&self) -> &[DiscoveredFile] {
43        &self.files
44    }
45
46    /// All discovered workspace packages.
47    pub fn workspaces(&self) -> &[WorkspaceInfo] {
48        &self.workspaces
49    }
50
51    /// Look up a file by its `FileId`.
52    pub fn file_by_id(&self, id: FileId) -> Option<&DiscoveredFile> {
53        self.files.get(id.0 as usize)
54    }
55
56    /// Look up a `FileId` by absolute path.
57    pub fn id_for_path(&self, path: &Path) -> Option<FileId> {
58        self.path_to_id.get(path).copied()
59    }
60
61    /// Find which workspace a file belongs to, if any.
62    pub fn workspace_for_file(&self, id: FileId) -> Option<&WorkspaceInfo> {
63        let path = &self.files.get(id.0 as usize)?.path;
64        self.workspaces.iter().find(|ws| path.starts_with(&ws.root))
65    }
66
67    /// Look up a workspace by package name.
68    pub fn workspace_by_name(&self, name: &str) -> Option<&WorkspaceInfo> {
69        self.workspaces.iter().find(|ws| ws.name == name)
70    }
71
72    /// Get all `FileId`s for files within a workspace.
73    pub fn files_in_workspace(&self, ws: &WorkspaceInfo) -> Vec<FileId> {
74        self.files
75            .iter()
76            .filter(|f| f.path.starts_with(&ws.root))
77            .map(|f| f.id)
78            .collect()
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    fn make_file(id: u32, path: &str) -> DiscoveredFile {
87        DiscoveredFile {
88            id: FileId(id),
89            path: PathBuf::from(path),
90            size_bytes: 100,
91        }
92    }
93
94    fn make_workspace(name: &str, root: &str) -> WorkspaceInfo {
95        WorkspaceInfo {
96            root: PathBuf::from(root),
97            name: name.to_string(),
98            is_internal_dependency: false,
99        }
100    }
101
102    #[test]
103    fn id_for_path_lookup() {
104        let files = vec![
105            make_file(0, "/project/packages/a/src/index.ts"),
106            make_file(1, "/project/packages/b/src/index.ts"),
107        ];
108        let state = ProjectState::new(files, vec![]);
109        assert_eq!(
110            state.id_for_path(Path::new("/project/packages/a/src/index.ts")),
111            Some(FileId(0))
112        );
113        assert_eq!(
114            state.id_for_path(Path::new("/project/packages/b/src/index.ts")),
115            Some(FileId(1))
116        );
117        assert_eq!(state.id_for_path(Path::new("/project/missing.ts")), None);
118    }
119
120    #[test]
121    fn workspace_for_file_lookup() {
122        let files = vec![
123            make_file(0, "/project/packages/ui/src/button.ts"),
124            make_file(1, "/project/src/app.ts"),
125        ];
126        let workspaces = vec![make_workspace("ui", "/project/packages/ui")];
127        let state = ProjectState::new(files, workspaces);
128
129        assert_eq!(
130            state.workspace_for_file(FileId(0)).map(|ws| &ws.name),
131            Some(&"ui".to_string())
132        );
133        assert!(state.workspace_for_file(FileId(1)).is_none());
134    }
135
136    #[test]
137    fn workspace_by_name_lookup() {
138        let workspaces = vec![
139            make_workspace("ui", "/project/packages/ui"),
140            make_workspace("core", "/project/packages/core"),
141        ];
142        let state = ProjectState::new(vec![], workspaces);
143
144        assert!(state.workspace_by_name("ui").is_some());
145        assert!(state.workspace_by_name("core").is_some());
146        assert!(state.workspace_by_name("missing").is_none());
147    }
148
149    #[test]
150    fn files_in_workspace() {
151        let files = vec![
152            make_file(0, "/project/packages/ui/src/a.ts"),
153            make_file(1, "/project/packages/ui/src/b.ts"),
154            make_file(2, "/project/packages/core/src/c.ts"),
155            make_file(3, "/project/src/app.ts"),
156        ];
157        let workspaces = vec![
158            make_workspace("ui", "/project/packages/ui"),
159            make_workspace("core", "/project/packages/core"),
160        ];
161        let state = ProjectState::new(files, workspaces);
162
163        let ui_ws = state.workspace_by_name("ui").unwrap();
164        let ui_files = state.files_in_workspace(ui_ws);
165        assert_eq!(ui_files, vec![FileId(0), FileId(1)]);
166
167        let core_ws = state.workspace_by_name("core").unwrap();
168        let core_files = state.files_in_workspace(core_ws);
169        assert_eq!(core_files, vec![FileId(2)]);
170    }
171}