Skip to main content

lago_fs/
tree.rs

1use lago_core::ManifestEntry;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeSet;
4
5use crate::manifest::Manifest;
6
7/// An entry returned when listing a directory's immediate children.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub enum TreeEntry {
10    /// A file entry with its name and manifest data.
11    File { name: String, entry: ManifestEntry },
12    /// A subdirectory identified by name.
13    Directory { name: String },
14}
15
16/// List the immediate children (files and subdirectories) of the given directory path.
17///
18/// The `path` should be a directory prefix such as `"/"` or `"/src"`.
19/// Trailing slashes are normalized. The function inspects all manifest entries
20/// under the prefix and groups them into files (exact depth + 1) and
21/// directories (deeper entries collapsed to their first component).
22pub fn list_directory(manifest: &Manifest, path: &str) -> Vec<TreeEntry> {
23    let prefix = if path.ends_with('/') {
24        path.to_string()
25    } else {
26        format!("{path}/")
27    };
28
29    let mut files: Vec<TreeEntry> = Vec::new();
30    let mut dirs: BTreeSet<String> = BTreeSet::new();
31
32    for (entry_path, entry) in manifest.entries() {
33        // Skip the directory sentinel itself
34        if entry_path == path {
35            continue;
36        }
37
38        let Some(suffix) = entry_path.strip_prefix(&prefix) else {
39            continue;
40        };
41
42        if suffix.is_empty() {
43            continue;
44        }
45
46        if let Some(slash_pos) = suffix.find('/') {
47            // This entry is in a subdirectory
48            let dir_name = &suffix[..slash_pos];
49            dirs.insert(dir_name.to_string());
50        } else {
51            // This entry is an immediate child file
52            // Skip directory sentinel entries that happen to match
53            if entry
54                .content_type
55                .as_deref()
56                .is_some_and(|ct| ct == "inode/directory")
57            {
58                dirs.insert(suffix.to_string());
59            } else {
60                files.push(TreeEntry::File {
61                    name: suffix.to_string(),
62                    entry: entry.clone(),
63                });
64            }
65        }
66    }
67
68    let mut result: Vec<TreeEntry> = dirs
69        .into_iter()
70        .map(|name| TreeEntry::Directory { name })
71        .collect();
72    result.append(&mut files);
73    result
74}
75
76/// Recursively walk all file entries under the given path prefix.
77///
78/// Returns only actual file entries (not directory sentinels).
79pub fn walk<'a>(manifest: &'a Manifest, path: &str) -> Vec<&'a ManifestEntry> {
80    let prefix = if path.ends_with('/') {
81        path.to_string()
82    } else {
83        format!("{path}/")
84    };
85
86    manifest
87        .entries()
88        .range(prefix.clone()..)
89        .take_while(|(k, _)| k.starts_with(&prefix))
90        .filter(|(_, entry)| {
91            entry
92                .content_type
93                .as_deref()
94                .is_none_or(|ct| ct != "inode/directory")
95        })
96        .map(|(_, v)| v)
97        .collect()
98}
99
100/// Extract all parent directory paths for a given path.
101///
102/// For example, `"/a/b/c.txt"` yields `["/a", "/a/b"]`.
103/// Root `"/"` is not included.
104pub fn parent_dirs(path: &str) -> Vec<String> {
105    let mut dirs = Vec::new();
106    let mut current = String::new();
107
108    // Skip leading slash
109    let trimmed = path.strip_prefix('/').unwrap_or(path);
110
111    let parts: Vec<&str> = trimmed.split('/').collect();
112    // Iterate all parts except the last (the filename)
113    for part in &parts[..parts.len().saturating_sub(1)] {
114        current.push('/');
115        current.push_str(part);
116        dirs.push(current.clone());
117    }
118
119    dirs
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use lago_core::BlobHash;
126
127    fn make_manifest() -> Manifest {
128        let mut m = Manifest::new();
129        m.apply_write(
130            "/src/main.rs".to_string(),
131            BlobHash::from_hex("aaa"),
132            100,
133            Some("text/x-rust".to_string()),
134            1,
135        );
136        m.apply_write(
137            "/src/lib.rs".to_string(),
138            BlobHash::from_hex("bbb"),
139            200,
140            Some("text/x-rust".to_string()),
141            2,
142        );
143        m.apply_write(
144            "/src/util/helpers.rs".to_string(),
145            BlobHash::from_hex("ccc"),
146            50,
147            Some("text/x-rust".to_string()),
148            3,
149        );
150        m.apply_write(
151            "/README.md".to_string(),
152            BlobHash::from_hex("ddd"),
153            300,
154            Some("text/markdown".to_string()),
155            4,
156        );
157        m
158    }
159
160    #[test]
161    fn list_root_directory() {
162        let m = make_manifest();
163        let entries = list_directory(&m, "/");
164        let names: Vec<String> = entries
165            .iter()
166            .map(|e| match e {
167                TreeEntry::File { name, .. } => name.clone(),
168                TreeEntry::Directory { name } => format!("{name}/"),
169            })
170            .collect();
171        assert!(names.contains(&"src/".to_string()));
172        assert!(names.contains(&"README.md".to_string()));
173    }
174
175    #[test]
176    fn list_src_directory() {
177        let m = make_manifest();
178        let entries = list_directory(&m, "/src");
179        let names: Vec<String> = entries
180            .iter()
181            .map(|e| match e {
182                TreeEntry::File { name, .. } => name.clone(),
183                TreeEntry::Directory { name } => format!("{name}/"),
184            })
185            .collect();
186        assert!(names.contains(&"main.rs".to_string()));
187        assert!(names.contains(&"lib.rs".to_string()));
188        assert!(names.contains(&"util/".to_string()));
189    }
190
191    #[test]
192    fn walk_all_files() {
193        let m = make_manifest();
194        let files = walk(&m, "/");
195        // Should contain main.rs, lib.rs, helpers.rs, README.md
196        assert_eq!(files.len(), 4);
197    }
198
199    #[test]
200    fn walk_subdirectory() {
201        let m = make_manifest();
202        let files = walk(&m, "/src");
203        // main.rs, lib.rs, helpers.rs
204        assert_eq!(files.len(), 3);
205    }
206
207    #[test]
208    fn parent_dirs_deep_path() {
209        let dirs = parent_dirs("/a/b/c/d.txt");
210        assert_eq!(dirs, vec!["/a", "/a/b", "/a/b/c"]);
211    }
212
213    #[test]
214    fn parent_dirs_shallow_path() {
215        let dirs = parent_dirs("/file.txt");
216        assert!(dirs.is_empty());
217    }
218
219    #[test]
220    fn parent_dirs_two_levels() {
221        let dirs = parent_dirs("/src/main.rs");
222        assert_eq!(dirs, vec!["/src"]);
223    }
224}