Skip to main content

hd_mount/
projected.rs

1use hd_cas::ContentHash;
2use hd_engine::{Dag, Node};
3
4use crate::overlay::Overlay;
5
6#[derive(Debug, thiserror::Error)]
7pub enum ProjectedError {
8    #[error("path not found: {0}")]
9    NotFound(String),
10    #[error("not a file: {0}")]
11    NotAFile(String),
12    #[error("not a directory: {0}")]
13    NotADirectory(String),
14    #[error("CAS error: {0}")]
15    Store(#[from] hd_cas::store::StoreError),
16}
17
18/// A projected filesystem that resolves paths against the DAG and serves
19/// file content from the CAS. An overlay captures writes.
20pub struct ProjectedFs {
21    dag: Dag,
22    root: ContentHash,
23    overlay: Overlay,
24}
25
26impl ProjectedFs {
27    pub fn new(dag: Dag, root: ContentHash) -> Self {
28        ProjectedFs {
29            dag,
30            root,
31            overlay: Overlay::new(),
32        }
33    }
34
35    /// Read a file's contents. Checks the overlay first, then the DAG/CAS.
36    pub fn read_file(&self, path: &str) -> Result<Vec<u8>, ProjectedError> {
37        // Check overlay first
38        if self.overlay.is_deleted(path) {
39            return Err(ProjectedError::NotFound(path.to_string()));
40        }
41        if let Some(data) = self.overlay.read(path) {
42            return Ok(data.to_vec());
43        }
44
45        // Fall through to DAG
46        let node = self.dag.query(&self.root, path)
47            .ok_or_else(|| ProjectedError::NotFound(path.to_string()))?;
48
49        match node {
50            Node::File { manifest_hash, .. } => {
51                let manifest = self.dag.store().get_manifest(manifest_hash)?;
52                let mut data = Vec::with_capacity(manifest.size as usize);
53                for chunk_hash in &manifest.chunks {
54                    let chunk = self.dag.store().get_chunk(chunk_hash)?;
55                    data.extend_from_slice(&chunk);
56                }
57                Ok(data)
58            }
59            _ => Err(ProjectedError::NotAFile(path.to_string())),
60        }
61    }
62
63    /// List entries in a directory.
64    pub fn list_dir(&self, path: &str) -> Result<Vec<String>, ProjectedError> {
65        if path.is_empty() {
66            // Root: list children of the EnvNode
67            let root_node = self.dag.get(&self.root)
68                .ok_or_else(|| ProjectedError::NotFound("root".to_string()))?;
69            match root_node {
70                Node::Env { children, .. } => {
71                    let mut entries = Vec::new();
72                    for child_hash in children {
73                        if let Some(child) = self.dag.get(child_hash) {
74                            if let Some(name) = node_name(child) {
75                                entries.push(name);
76                            }
77                        }
78                    }
79                    Ok(entries)
80                }
81                _ => Err(ProjectedError::NotADirectory(path.to_string())),
82            }
83        } else {
84            let node = self.dag.query(&self.root, path)
85                .ok_or_else(|| ProjectedError::NotFound(path.to_string()))?;
86            match node {
87                Node::Dir { children, .. } => {
88                    Ok(children.iter().map(|(name, _)| name.clone()).collect())
89                }
90                _ => Err(ProjectedError::NotADirectory(path.to_string())),
91            }
92        }
93    }
94
95    /// Check if a path exists (file or directory).
96    pub fn exists(&self, path: &str) -> bool {
97        if self.overlay.is_deleted(path) {
98            return false;
99        }
100        if self.overlay.read(path).is_some() {
101            return true;
102        }
103        self.dag.query(&self.root, path).is_some()
104    }
105
106    /// Get a mutable reference to the overlay.
107    pub fn overlay_mut(&mut self) -> &mut Overlay {
108        &mut self.overlay
109    }
110
111    /// Get a reference to the overlay.
112    pub fn overlay(&self) -> &Overlay {
113        &self.overlay
114    }
115
116    /// Get the current root hash.
117    pub fn root(&self) -> &ContentHash {
118        &self.root
119    }
120
121    /// Update the root hash (after DAG invalidation).
122    pub fn set_root(&mut self, root: ContentHash) {
123        self.root = root;
124    }
125
126    /// Get a reference to the DAG.
127    pub fn dag(&self) -> &Dag {
128        &self.dag
129    }
130}
131
132/// Extract the display name from a node (last path component for Dir, filename for File).
133fn node_name(node: &Node) -> Option<String> {
134    match node {
135        Node::Dir { path, .. } => {
136            Some(path.rsplit('/').next().unwrap_or(path).to_string())
137        }
138        Node::File { path, .. } => {
139            Some(path.rsplit('/').next().unwrap_or(path).to_string())
140        }
141        _ => None,
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use hd_cas::ContentStore;
149    use tempfile::TempDir;
150
151    fn setup() -> (ProjectedFs, TempDir) {
152        let dir = TempDir::new().unwrap();
153        let store = ContentStore::open(dir.path()).unwrap();
154
155        // Store some file content in the CAS
156        let file_data = b"fn main() { println!(\"hello\"); }";
157        let manifest_hash = store.put_file_from_bytes(file_data, 0o644).unwrap();
158
159        let lib_data = b"pub fn greet() {}";
160        let lib_manifest_hash = store.put_file_from_bytes(lib_data, 0o644).unwrap();
161
162        // Build DAG: env -> dir("src") -> {main.rs, lib.rs}
163        let mut dag = Dag::new(store);
164        let main_node = Node::file("src/main.rs", manifest_hash);
165        let main_hash = dag.insert(main_node).unwrap();
166        let lib_node = Node::file("src/lib.rs", lib_manifest_hash);
167        let lib_hash = dag.insert(lib_node).unwrap();
168        let src_dir = Node::dir("src", vec![
169            ("main.rs".into(), main_hash),
170            ("lib.rs".into(), lib_hash),
171        ]);
172        let src_hash = dag.insert(src_dir).unwrap();
173        let env = Node::env("testapp", vec![src_hash]);
174        let env_hash = dag.insert(env).unwrap();
175
176        let fs = ProjectedFs::new(dag, env_hash);
177        (fs, dir)
178    }
179
180    #[test]
181    fn read_file_from_dag() {
182        let (fs, _dir) = setup();
183        let data = fs.read_file("src/main.rs").unwrap();
184        assert_eq!(data, b"fn main() { println!(\"hello\"); }");
185    }
186
187    #[test]
188    fn read_nonexistent_file() {
189        let (fs, _dir) = setup();
190        assert!(fs.read_file("missing.rs").is_err());
191    }
192
193    #[test]
194    fn list_directory() {
195        let (fs, _dir) = setup();
196        let entries = fs.list_dir("src").unwrap();
197        assert_eq!(entries.len(), 2);
198        assert!(entries.contains(&"main.rs".to_string()));
199        assert!(entries.contains(&"lib.rs".to_string()));
200    }
201
202    #[test]
203    fn list_root() {
204        let (fs, _dir) = setup();
205        let entries = fs.list_dir("").unwrap();
206        assert!(entries.contains(&"src".to_string()));
207    }
208
209    #[test]
210    fn overlay_overrides_dag() {
211        let (mut fs, _dir) = setup();
212        fs.overlay_mut().write("src/main.rs", b"// modified".to_vec());
213        let data = fs.read_file("src/main.rs").unwrap();
214        assert_eq!(data, b"// modified");
215    }
216
217    #[test]
218    fn overlay_adds_new_file() {
219        let (mut fs, _dir) = setup();
220        fs.overlay_mut().write("src/new.rs", b"// new file".to_vec());
221        let data = fs.read_file("src/new.rs").unwrap();
222        assert_eq!(data, b"// new file");
223    }
224
225    #[test]
226    fn overlay_deletes_dag_file() {
227        let (mut fs, _dir) = setup();
228        fs.overlay_mut().delete("src/main.rs");
229        assert!(fs.read_file("src/main.rs").is_err());
230    }
231
232    #[test]
233    fn file_exists() {
234        let (fs, _dir) = setup();
235        assert!(fs.exists("src/main.rs"));
236        assert!(fs.exists("src"));
237        assert!(!fs.exists("missing"));
238    }
239}