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