Skip to main content

radicle_surf/
fs.rs

1//! Definition for a file system consisting of `Directory` and `File`.
2//!
3//! A `Directory` is expected to be a non-empty tree of directories and files.
4//! See [`Directory`] for more information.
5
6use std::{
7    cmp::Ordering,
8    collections::BTreeMap,
9    convert::{Infallible, Into as _},
10    path::{Path, PathBuf},
11};
12
13use git2::Blob;
14use radicle_git_ext::{is_not_found_err, Oid};
15use radicle_std_ext::result::ResultExt as _;
16use url::Url;
17
18use crate::{Repository, Revision};
19
20pub mod error {
21    use std::path::PathBuf;
22
23    use thiserror::Error;
24
25    #[derive(Debug, Error, PartialEq)]
26    pub enum Directory {
27        #[error(transparent)]
28        Git(#[from] git2::Error),
29        #[error(transparent)]
30        File(#[from] File),
31        #[error("the path {0} is not valid")]
32        InvalidPath(PathBuf),
33        #[error("the entry at '{0}' must be of type {1}")]
34        InvalidType(PathBuf, &'static str),
35        #[error("the entry name was not valid UTF-8")]
36        Utf8Error,
37        #[error("the path {0} not found")]
38        PathNotFound(PathBuf),
39        #[error(transparent)]
40        Submodule(#[from] Submodule),
41    }
42
43    #[derive(Debug, Error, PartialEq)]
44    pub enum File {
45        #[error(transparent)]
46        Git(#[from] git2::Error),
47    }
48
49    #[derive(Debug, Error, PartialEq)]
50    pub enum Submodule {
51        #[error("URL is invalid utf-8 for submodule '{name}': {err}")]
52        Utf8 {
53            name: String,
54            #[source]
55            err: std::str::Utf8Error,
56        },
57        #[error("failed to parse URL '{url}' for submodule '{name}': {err}")]
58        ParseUrl {
59            name: String,
60            url: String,
61            #[source]
62            err: url::ParseError,
63        },
64    }
65}
66
67/// A `File` in a git repository.
68///
69/// The representation is lightweight and contains the [`Oid`] that
70/// points to the git blob which is this file.
71///
72/// The name of a file can be retrieved via [`File::name`].
73///
74/// The [`FileContent`] of a file can be retrieved via
75/// [`File::content`].
76#[derive(Clone, PartialEq, Eq, Debug)]
77pub struct File {
78    /// The name of the file.
79    name: String,
80    /// The relative path of the file, not including the `name`,
81    /// in respect to the root of the git repository.
82    prefix: PathBuf,
83    /// The object identifier of the git blob of this file.
84    id: Oid,
85}
86
87impl File {
88    /// Construct a new `File`.
89    ///
90    /// The `path` must be the prefix location of the directory, and
91    /// so should not end in `name`.
92    ///
93    /// The `id` must point to a git blob.
94    pub(crate) fn new(name: String, prefix: PathBuf, id: Oid) -> Self {
95        debug_assert!(
96            !prefix.ends_with(&name),
97            "prefix = {prefix:?}, name = {name}",
98        );
99        Self { name, prefix, id }
100    }
101
102    /// The name of this `File`.
103    pub fn name(&self) -> &str {
104        self.name.as_str()
105    }
106
107    /// The object identifier of this `File`.
108    pub fn id(&self) -> Oid {
109        self.id
110    }
111
112    /// Return the exact path for this `File`, including the `name` of
113    /// the directory itself.
114    ///
115    /// The path is relative to the git repository root.
116    pub fn path(&self) -> PathBuf {
117        self.prefix.join(&self.name)
118    }
119
120    /// Return the [`Path`] where this `File` is located, relative to the
121    /// git repository root.
122    pub fn location(&self) -> &Path {
123        &self.prefix
124    }
125
126    /// Get the [`FileContent`] for this `File`.
127    ///
128    /// # Errors
129    ///
130    /// This function will fail if it could not find the `git` blob
131    /// for the `Oid` of this `File`.
132    pub fn content<'a>(&self, repo: &'a Repository) -> Result<FileContent<'a>, error::File> {
133        let blob = repo.find_blob(self.id)?;
134        Ok(FileContent { blob })
135    }
136}
137
138/// The contents of a [`File`].
139///
140/// To construct a `FileContent` use [`File::content`].
141pub struct FileContent<'a> {
142    blob: Blob<'a>,
143}
144
145impl<'a> FileContent<'a> {
146    /// Return the file contents as a byte slice.
147    pub fn as_bytes(&self) -> &[u8] {
148        self.blob.content()
149    }
150
151    /// Return the size of the file contents.
152    pub fn size(&self) -> usize {
153        self.blob.size()
154    }
155
156    /// Creates a `FileContent` using a blob.
157    pub(crate) fn new(blob: Blob<'a>) -> Self {
158        Self { blob }
159    }
160}
161
162/// A representations of a [`Directory`]'s entries.
163pub struct Entries {
164    listing: BTreeMap<String, Entry>,
165}
166
167impl Entries {
168    /// Return the name of each [`Entry`].
169    pub fn names(&self) -> impl Iterator<Item = &String> {
170        self.listing.keys()
171    }
172
173    /// Return each [`Entry`].
174    pub fn entries(&self) -> impl Iterator<Item = &Entry> {
175        self.listing.values()
176    }
177
178    /// Return each [`Entry`] and its name.
179    pub fn iter(&self) -> impl Iterator<Item = (&String, &Entry)> {
180        self.listing.iter()
181    }
182}
183
184impl Iterator for Entries {
185    type Item = Entry;
186
187    fn next(&mut self) -> Option<Self::Item> {
188        // Can be improved when `pop_first()` is stable for BTreeMap.
189        let next_key = match self.listing.keys().next() {
190            Some(k) => k.clone(),
191            None => return None,
192        };
193        self.listing.remove(&next_key)
194    }
195}
196
197/// An `Entry` is either a [`File`] entry or a [`Directory`] entry.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum Entry {
200    /// A file entry within a [`Directory`].
201    File(File),
202    /// A sub-directory of a [`Directory`].
203    Directory(Directory),
204    /// An entry points to a submodule.
205    Submodule(Submodule),
206}
207
208impl PartialOrd for Entry {
209    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
210        Some(self.cmp(other))
211    }
212}
213
214impl Ord for Entry {
215    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
216        match (self, other) {
217            (Entry::File(x), Entry::File(y)) => x.name().cmp(y.name()),
218            (Entry::File(_), Entry::Directory(_)) => Ordering::Less,
219            (Entry::File(_), Entry::Submodule(_)) => Ordering::Less,
220            (Entry::Directory(_), Entry::File(_)) => Ordering::Greater,
221            (Entry::Submodule(_), Entry::File(_)) => Ordering::Less,
222            (Entry::Directory(x), Entry::Directory(y)) => x.name().cmp(y.name()),
223            (Entry::Directory(x), Entry::Submodule(y)) => x.name().cmp(y.name()),
224            (Entry::Submodule(x), Entry::Directory(y)) => x.name().cmp(y.name()),
225            (Entry::Submodule(x), Entry::Submodule(y)) => x.name().cmp(y.name()),
226        }
227    }
228}
229
230impl Entry {
231    /// Get a label for the `Entriess`, either the name of the [`File`],
232    /// the name of the [`Directory`], or the name of the [`Submodule`].
233    pub fn name(&self) -> &String {
234        match self {
235            Entry::File(file) => &file.name,
236            Entry::Directory(directory) => directory.name(),
237            Entry::Submodule(submodule) => submodule.name(),
238        }
239    }
240
241    pub fn path(&self) -> PathBuf {
242        match self {
243            Entry::File(file) => file.path(),
244            Entry::Directory(directory) => directory.path(),
245            Entry::Submodule(submodule) => submodule.path(),
246        }
247    }
248
249    pub fn location(&self) -> &Path {
250        match self {
251            Entry::File(file) => file.location(),
252            Entry::Directory(directory) => directory.location(),
253            Entry::Submodule(submodule) => submodule.location(),
254        }
255    }
256
257    /// Returns `true` if the `Entry` is a file.
258    pub fn is_file(&self) -> bool {
259        matches!(self, Entry::File(_))
260    }
261
262    /// Returns `true` if the `Entry` is a directory.
263    pub fn is_directory(&self) -> bool {
264        matches!(self, Entry::Directory(_))
265    }
266
267    pub(crate) fn from_entry(
268        entry: &git2::TreeEntry,
269        path: PathBuf,
270        repo: &Repository,
271    ) -> Result<Self, error::Directory> {
272        let name = entry
273            .name()
274            .map_err(|_| error::Directory::Utf8Error)?
275            .to_string();
276        let id = entry.id().into();
277
278        match entry.kind() {
279            Some(git2::ObjectType::Tree) => Ok(Self::Directory(Directory::new(name, path, id))),
280            Some(git2::ObjectType::Blob) => Ok(Self::File(File::new(name, path, id))),
281            Some(git2::ObjectType::Commit) => {
282                let submodule = (!repo.is_bare())
283                    .then(|| repo.find_submodule(&name))
284                    .transpose()?;
285                Ok(Self::Submodule(Submodule::new(name, path, submodule, id)?))
286            }
287            _ => Err(error::Directory::InvalidType(path, "tree or blob")),
288        }
289    }
290}
291
292/// A `Directory` is the representation of a file system directory, for a given
293/// [`git` tree][git-tree].
294///
295/// The name of a directory can be retrieved via [`File::name`].
296///
297/// The [`Entries`] of a directory can be retrieved via
298/// [`Directory::entries`].
299///
300/// [git-tree]: https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct Directory {
303    /// The name of the directoy.
304    name: String,
305    /// The relative path of the directory, not including the `name`,
306    /// in respect to the root of the git repository.
307    prefix: PathBuf,
308    /// The object identifier of the git tree of this directory.
309    id: Oid,
310}
311
312const ROOT_DIR: &str = "";
313
314impl Directory {
315    /// Creates a directory given its `tree_id`.
316    ///
317    /// The `name` and `prefix` are both set to be empty.
318    pub(crate) fn root(id: Oid) -> Self {
319        Self::new(ROOT_DIR.to_string(), PathBuf::new(), id)
320    }
321
322    /// Creates a directory given its `name` and `id`.
323    ///
324    /// The `path` must be the prefix location of the directory, and
325    /// so should not end in `name`.
326    ///
327    /// The `id` must point to a `git` tree.
328    pub(crate) fn new(name: String, prefix: PathBuf, id: Oid) -> Self {
329        debug_assert!(
330            name.is_empty() || !prefix.ends_with(&name),
331            "prefix = {prefix:?}, name = {name}",
332        );
333        Self { name, prefix, id }
334    }
335
336    /// Get the name of the current `Directory`.
337    pub fn name(&self) -> &String {
338        &self.name
339    }
340
341    /// The object identifier of this `[Directory]`.
342    pub fn id(&self) -> Oid {
343        self.id
344    }
345
346    /// Return the exact path for this `Directory`, including the `name` of the
347    /// directory itself.
348    ///
349    /// The path is relative to the git repository root.
350    pub fn path(&self) -> PathBuf {
351        self.prefix.join(&self.name)
352    }
353
354    /// Return the [`Path`] where this `Directory` is located, relative to the
355    /// git repository root.
356    pub fn location(&self) -> &Path {
357        &self.prefix
358    }
359
360    /// Return the [`Entries`] for this `Directory`'s `Oid`.
361    ///
362    /// The resulting `Entries` will only resolve to this
363    /// `Directory`'s entries. Any sub-directories will need to be
364    /// resolved independently.
365    ///
366    /// # Errors
367    ///
368    /// This function will fail if it could not find the `git` tree
369    /// for the `Oid`.
370    pub fn entries(&self, repo: &Repository) -> Result<Entries, error::Directory> {
371        let tree = repo.find_tree(self.id)?;
372
373        let mut entries = BTreeMap::new();
374        let mut error = None;
375        let path = self.path();
376
377        // Walks only the first level of entries. And `_entry_path` is always
378        // empty for the first level.
379        tree.walk(git2::TreeWalkMode::PreOrder, |_entry_path, entry| {
380            match Entry::from_entry(entry, path.clone(), repo) {
381                Ok(entry) => match entry {
382                    Entry::File(_) => {
383                        entries.insert(entry.name().clone(), entry);
384                        git2::TreeWalkResult::Ok
385                    }
386                    Entry::Directory(_) => {
387                        entries.insert(entry.name().clone(), entry);
388                        // Skip nested directories
389                        git2::TreeWalkResult::Skip
390                    }
391                    Entry::Submodule(_) => {
392                        entries.insert(entry.name().clone(), entry);
393                        git2::TreeWalkResult::Ok
394                    }
395                },
396                Err(err) => {
397                    error = Some(err);
398                    git2::TreeWalkResult::Abort
399                }
400            }
401        })?;
402
403        match error {
404            Some(err) => Err(err),
405            None => Ok(Entries { listing: entries }),
406        }
407    }
408
409    /// Find the [`Entry`] found at a non-empty `path`, if it exists.
410    pub fn find_entry<P>(&self, path: &P, repo: &Repository) -> Result<Entry, error::Directory>
411    where
412        P: AsRef<Path>,
413    {
414        // Search the path in git2 tree.
415        let path = path.as_ref();
416        let git2_tree = repo.find_tree(self.id)?;
417        let entry = git2_tree
418            .get_path(path)
419            .or_matches::<error::Directory, _, _>(is_not_found_err, || {
420                Err(error::Directory::PathNotFound(path.to_path_buf()))
421            })?;
422        let parent = path
423            .parent()
424            .ok_or_else(|| error::Directory::InvalidPath(path.to_path_buf()))?;
425        let root_path = self.path().join(parent);
426
427        Entry::from_entry(&entry, root_path, repo)
428    }
429
430    /// Find the `Oid`, for a [`File`], found at `path`, if it exists.
431    pub fn find_file<P>(&self, path: &P, repo: &Repository) -> Result<File, error::Directory>
432    where
433        P: AsRef<Path>,
434    {
435        match self.find_entry(path, repo)? {
436            Entry::File(file) => Ok(file),
437            _ => Err(error::Directory::InvalidType(
438                path.as_ref().to_path_buf(),
439                "file",
440            )),
441        }
442    }
443
444    /// Find the `Directory` found at `path`, if it exists.
445    ///
446    /// If `path` is `ROOT_DIR` (i.e. an empty path), returns self.
447    pub fn find_directory<P>(&self, path: &P, repo: &Repository) -> Result<Self, error::Directory>
448    where
449        P: AsRef<Path>,
450    {
451        if path.as_ref() == Path::new(ROOT_DIR) {
452            return Ok(self.clone());
453        }
454
455        match self.find_entry(path, repo)? {
456            Entry::Directory(d) => Ok(d),
457            _ => Err(error::Directory::InvalidType(
458                path.as_ref().to_path_buf(),
459                "directory",
460            )),
461        }
462    }
463
464    // TODO(fintan): This is going to be a bit trickier so going to leave it out for
465    // now
466    #[allow(dead_code)]
467    fn fuzzy_find(_label: &Path) -> Vec<Self> {
468        unimplemented!()
469    }
470
471    /// Get the total size, in bytes, of a `Directory`. The size is
472    /// the sum of all files that can be reached from this `Directory`.
473    pub fn size(&self, repo: &Repository) -> Result<usize, error::Directory> {
474        self.traverse(repo, 0, &mut |size, entry| match entry {
475            Entry::File(file) => Ok(size + file.content(repo)?.size()),
476            Entry::Directory(dir) => Ok(size + dir.size(repo)?),
477            Entry::Submodule(_) => Ok(size),
478        })
479    }
480
481    /// Traverse the entire `Directory` using the `initial`
482    /// accumulator and the function `f`.
483    ///
484    /// For each [`Entry::Directory`] this will recursively call
485    /// [`Directory::traverse`] and obtain its [`Entries`].
486    ///
487    /// `Error` is the error type of the fallible function.
488    /// `B` is the type of the accumulator.
489    /// `F` is the fallible function that takes the accumulator and
490    /// the next [`Entry`], possibly providing the next accumulator
491    /// value.
492    pub fn traverse<Error, B, F>(
493        &self,
494        repo: &Repository,
495        initial: B,
496        f: &mut F,
497    ) -> Result<B, Error>
498    where
499        Error: From<error::Directory>,
500        F: FnMut(B, &Entry) -> Result<B, Error>,
501    {
502        self.entries(repo)?
503            .entries()
504            .try_fold(initial, |acc, entry| match entry {
505                Entry::File(_) => f(acc, entry),
506                Entry::Directory(directory) => {
507                    let acc = directory.traverse(repo, acc, f)?;
508                    f(acc, entry)
509                }
510                Entry::Submodule(_) => f(acc, entry),
511            })
512    }
513}
514
515impl Revision for Directory {
516    type Error = Infallible;
517
518    fn object_id(&self, _repo: &Repository) -> Result<Oid, Self::Error> {
519        Ok(self.id)
520    }
521}
522
523/// A representation of a Git [submodule] when encountered in a Git
524/// repository.
525///
526/// [submodule]: https://git-scm.com/book/en/v2/Git-Tools-Submodules
527#[derive(Debug, Clone, PartialEq, Eq)]
528pub struct Submodule {
529    name: String,
530    prefix: PathBuf,
531    id: Oid,
532    url: Option<Url>,
533}
534
535impl Submodule {
536    /// Construct a new `Submodule`.
537    ///
538    /// The `path` must be the prefix location of the directory, and
539    /// so should not end in `name`.
540    ///
541    /// The `id` is the commit pointer that Git provides when listing
542    /// a submodule.
543    pub fn new(
544        name: String,
545        prefix: PathBuf,
546        submodule: Option<git2::Submodule>,
547        id: Oid,
548    ) -> Result<Self, error::Submodule> {
549        let url = submodule
550            .and_then(|module| {
551                module
552                    .opt_url_bytes()
553                    .map(|bs| std::str::from_utf8(bs).map(|url| url.to_string()))
554            })
555            .transpose()
556            .map_err(|err| error::Submodule::Utf8 {
557                name: name.clone(),
558                err,
559            })?;
560        let url = url
561            .map(|url| {
562                Url::parse(&url).map_err(|err| error::Submodule::ParseUrl {
563                    name: name.clone(),
564                    url,
565                    err,
566                })
567            })
568            .transpose()?;
569        Ok(Self {
570            name,
571            prefix,
572            id,
573            url,
574        })
575    }
576
577    /// The name of this `Submodule`.
578    pub fn name(&self) -> &String {
579        &self.name
580    }
581
582    /// Return the [`Path`] where this `Submodule` is located, relative to the
583    /// git repository root.
584    pub fn location(&self) -> &Path {
585        &self.prefix
586    }
587
588    /// Return the exact path for this `Submodule`, including the
589    /// `name` of the submodule itself.
590    ///
591    /// The path is relative to the git repository root.
592    pub fn path(&self) -> PathBuf {
593        self.prefix.join(&self.name)
594    }
595
596    /// The object identifier of this `Submodule`.
597    ///
598    /// Note that this does not exist in the parent `Repository`. A
599    /// new `Repository` should be opened for the submodule.
600    pub fn id(&self) -> Oid {
601        self.id
602    }
603
604    /// The URL for the submodule, if it is defined.
605    pub fn url(&self) -> &Option<Url> {
606        &self.url
607    }
608}