jj_lib/
repo_path.rs

1// Copyright 2020 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15#![allow(missing_docs)]
16
17use std::borrow::Borrow;
18use std::cmp::Ordering;
19use std::fmt;
20use std::fmt::Debug;
21use std::fmt::Formatter;
22use std::iter::FusedIterator;
23use std::ops::Deref;
24use std::path::Component;
25use std::path::Path;
26use std::path::PathBuf;
27
28use itertools::Itertools as _;
29use ref_cast::ref_cast_custom;
30use ref_cast::RefCastCustom;
31use thiserror::Error;
32
33use crate::content_hash::ContentHash;
34use crate::file_util;
35
36/// Owned `RepoPath` component.
37#[derive(ContentHash, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
38pub struct RepoPathComponentBuf {
39    // Don't add more fields. Eq, Hash, and Ord must be compatible with the
40    // borrowed RepoPathComponent type.
41    value: String,
42}
43
44impl RepoPathComponentBuf {
45    /// Wraps `value` as `RepoPathComponentBuf`.
46    ///
47    /// Returns an error if the input `value` is empty or contains path
48    /// separator.
49    pub fn new(value: impl Into<String>) -> Result<Self, InvalidNewRepoPathError> {
50        let value: String = value.into();
51        if is_valid_repo_path_component_str(&value) {
52            Ok(Self { value })
53        } else {
54            Err(InvalidNewRepoPathError { value })
55        }
56    }
57}
58
59/// Borrowed `RepoPath` component.
60#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, RefCastCustom)]
61#[repr(transparent)]
62pub struct RepoPathComponent {
63    value: str,
64}
65
66impl RepoPathComponent {
67    /// Wraps `value` as `RepoPathComponent`.
68    ///
69    /// Returns an error if the input `value` is empty or contains path
70    /// separator.
71    pub fn new(value: &str) -> Result<&Self, InvalidNewRepoPathError> {
72        if is_valid_repo_path_component_str(value) {
73            Ok(Self::new_unchecked(value))
74        } else {
75            Err(InvalidNewRepoPathError {
76                value: value.to_string(),
77            })
78        }
79    }
80
81    #[ref_cast_custom]
82    const fn new_unchecked(value: &str) -> &Self;
83
84    /// Returns the underlying string representation.
85    pub fn as_internal_str(&self) -> &str {
86        &self.value
87    }
88
89    /// Returns a normal filesystem entry name if this path component is valid
90    /// as a file/directory name.
91    pub fn to_fs_name(&self) -> Result<&str, InvalidRepoPathComponentError> {
92        let mut components = Path::new(&self.value).components().fuse();
93        match (components.next(), components.next()) {
94            // Trailing "." can be normalized by Path::components(), so compare
95            // component name. e.g. "foo\." (on Windows) should be rejected.
96            (Some(Component::Normal(name)), None) if name == &self.value => Ok(&self.value),
97            // e.g. ".", "..", "foo\bar" (on Windows)
98            _ => Err(InvalidRepoPathComponentError {
99                component: self.value.into(),
100            }),
101        }
102    }
103}
104
105impl Debug for RepoPathComponent {
106    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
107        write!(f, "{:?}", &self.value)
108    }
109}
110
111impl Debug for RepoPathComponentBuf {
112    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
113        <RepoPathComponent as Debug>::fmt(self, f)
114    }
115}
116
117impl AsRef<RepoPathComponent> for RepoPathComponent {
118    fn as_ref(&self) -> &RepoPathComponent {
119        self
120    }
121}
122
123impl AsRef<RepoPathComponent> for RepoPathComponentBuf {
124    fn as_ref(&self) -> &RepoPathComponent {
125        self
126    }
127}
128
129impl Borrow<RepoPathComponent> for RepoPathComponentBuf {
130    fn borrow(&self) -> &RepoPathComponent {
131        self
132    }
133}
134
135impl Deref for RepoPathComponentBuf {
136    type Target = RepoPathComponent;
137
138    fn deref(&self) -> &Self::Target {
139        RepoPathComponent::new_unchecked(&self.value)
140    }
141}
142
143impl ToOwned for RepoPathComponent {
144    type Owned = RepoPathComponentBuf;
145
146    fn to_owned(&self) -> Self::Owned {
147        let value = self.value.to_owned();
148        RepoPathComponentBuf { value }
149    }
150
151    fn clone_into(&self, target: &mut Self::Owned) {
152        self.value.clone_into(&mut target.value);
153    }
154}
155
156/// Iterator over `RepoPath` components.
157#[derive(Clone, Debug)]
158pub struct RepoPathComponentsIter<'a> {
159    value: &'a str,
160}
161
162impl<'a> RepoPathComponentsIter<'a> {
163    /// Returns the remaining part as repository path.
164    pub fn as_path(&self) -> &'a RepoPath {
165        RepoPath::from_internal_string_unchecked(self.value)
166    }
167}
168
169impl<'a> Iterator for RepoPathComponentsIter<'a> {
170    type Item = &'a RepoPathComponent;
171
172    fn next(&mut self) -> Option<Self::Item> {
173        if self.value.is_empty() {
174            return None;
175        }
176        let (name, remainder) = self
177            .value
178            .split_once('/')
179            .unwrap_or_else(|| (self.value, &self.value[self.value.len()..]));
180        self.value = remainder;
181        Some(RepoPathComponent::new_unchecked(name))
182    }
183}
184
185impl DoubleEndedIterator for RepoPathComponentsIter<'_> {
186    fn next_back(&mut self) -> Option<Self::Item> {
187        if self.value.is_empty() {
188            return None;
189        }
190        let (remainder, name) = self
191            .value
192            .rsplit_once('/')
193            .unwrap_or_else(|| (&self.value[..0], self.value));
194        self.value = remainder;
195        Some(RepoPathComponent::new_unchecked(name))
196    }
197}
198
199impl FusedIterator for RepoPathComponentsIter<'_> {}
200
201/// Owned repository path.
202#[derive(Clone, Eq, Hash, PartialEq)]
203pub struct RepoPathBuf {
204    // Don't add more fields. Eq, Hash, and Ord must be compatible with the
205    // borrowed RepoPath type.
206    value: String,
207}
208
209/// Borrowed repository path.
210#[derive(Eq, Hash, PartialEq, RefCastCustom)]
211#[repr(transparent)]
212pub struct RepoPath {
213    value: str,
214}
215
216impl Debug for RepoPath {
217    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
218        write!(f, "{:?}", &self.value)
219    }
220}
221
222impl Debug for RepoPathBuf {
223    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
224        <RepoPath as Debug>::fmt(self, f)
225    }
226}
227
228/// The `value` is not a valid repo path because it contains empty path
229/// component. For example, `"/"`, `"/foo"`, `"foo/"`, `"foo//bar"` are all
230/// invalid.
231#[derive(Clone, Debug, Eq, Error, PartialEq)]
232#[error(r#"Invalid repo path input "{value}""#)]
233pub struct InvalidNewRepoPathError {
234    value: String,
235}
236
237impl RepoPathBuf {
238    /// Creates owned repository path pointing to the root.
239    pub const fn root() -> Self {
240        RepoPathBuf {
241            value: String::new(),
242        }
243    }
244
245    /// Creates `RepoPathBuf` from valid string representation.
246    pub fn from_internal_string(value: impl Into<String>) -> Result<Self, InvalidNewRepoPathError> {
247        let value: String = value.into();
248        if is_valid_repo_path_str(&value) {
249            Ok(RepoPathBuf { value })
250        } else {
251            Err(InvalidNewRepoPathError { value })
252        }
253    }
254
255    /// Converts repo-relative `Path` to `RepoPathBuf`.
256    ///
257    /// The input path should not contain redundant `.` or `..`.
258    pub fn from_relative_path(
259        relative_path: impl AsRef<Path>,
260    ) -> Result<Self, RelativePathParseError> {
261        let relative_path = relative_path.as_ref();
262        if relative_path == Path::new(".") {
263            return Ok(Self::root());
264        }
265
266        let mut components = relative_path
267            .components()
268            .map(|c| match c {
269                Component::Normal(name) => {
270                    name.to_str()
271                        .ok_or_else(|| RelativePathParseError::InvalidUtf8 {
272                            path: relative_path.into(),
273                        })
274                }
275                _ => Err(RelativePathParseError::InvalidComponent {
276                    component: c.as_os_str().to_string_lossy().into(),
277                    path: relative_path.into(),
278                }),
279            })
280            .fuse();
281        let mut value = String::with_capacity(relative_path.as_os_str().len());
282        if let Some(name) = components.next() {
283            value.push_str(name?);
284        }
285        for name in components {
286            value.push('/');
287            value.push_str(name?);
288        }
289        Ok(RepoPathBuf { value })
290    }
291
292    /// Parses an `input` path into a `RepoPathBuf` relative to `base`.
293    ///
294    /// The `cwd` and `base` paths are supposed to be absolute and normalized in
295    /// the same manner. The `input` path may be either relative to `cwd` or
296    /// absolute.
297    pub fn parse_fs_path(
298        cwd: &Path,
299        base: &Path,
300        input: impl AsRef<Path>,
301    ) -> Result<Self, FsPathParseError> {
302        let input = input.as_ref();
303        let abs_input_path = file_util::normalize_path(&cwd.join(input));
304        let repo_relative_path = file_util::relative_path(base, &abs_input_path);
305        Self::from_relative_path(repo_relative_path).map_err(|source| FsPathParseError {
306            base: file_util::relative_path(cwd, base).into(),
307            input: input.into(),
308            source,
309        })
310    }
311
312    /// Consumes this and returns the underlying string representation.
313    pub fn into_internal_string(self) -> String {
314        self.value
315    }
316}
317
318impl RepoPath {
319    /// Returns repository path pointing to the root.
320    pub const fn root() -> &'static Self {
321        Self::from_internal_string_unchecked("")
322    }
323
324    /// Wraps valid string representation as `RepoPath`.
325    ///
326    /// Returns an error if the input `value` contains empty path component. For
327    /// example, `"/"`, `"/foo"`, `"foo/"`, `"foo//bar"` are all invalid.
328    pub fn from_internal_string(value: &str) -> Result<&Self, InvalidNewRepoPathError> {
329        if is_valid_repo_path_str(value) {
330            Ok(Self::from_internal_string_unchecked(value))
331        } else {
332            Err(InvalidNewRepoPathError {
333                value: value.to_owned(),
334            })
335        }
336    }
337
338    #[ref_cast_custom]
339    const fn from_internal_string_unchecked(value: &str) -> &Self;
340
341    /// The full string form used internally, not for presenting to users (where
342    /// we may want to use the platform's separator). This format includes a
343    /// trailing slash, unless this path represents the root directory. That
344    /// way it can be concatenated with a basename and produce a valid path.
345    pub fn to_internal_dir_string(&self) -> String {
346        if self.value.is_empty() {
347            String::new()
348        } else {
349            [&self.value, "/"].concat()
350        }
351    }
352
353    /// The full string form used internally, not for presenting to users (where
354    /// we may want to use the platform's separator).
355    pub fn as_internal_file_string(&self) -> &str {
356        &self.value
357    }
358
359    /// Converts repository path to filesystem path relative to the `base`.
360    ///
361    /// The returned path should never contain `..`, `C:` (on Windows), etc.
362    /// However, it may contain reserved working-copy directories such as `.jj`.
363    pub fn to_fs_path(&self, base: &Path) -> Result<PathBuf, InvalidRepoPathError> {
364        let mut result = PathBuf::with_capacity(base.as_os_str().len() + self.value.len() + 1);
365        result.push(base);
366        for c in self.components() {
367            result.push(c.to_fs_name().map_err(|err| err.with_path(self))?);
368        }
369        if result.as_os_str().is_empty() {
370            result.push(".");
371        }
372        Ok(result)
373    }
374
375    /// Converts repository path to filesystem path relative to the `base`,
376    /// without checking invalid path components.
377    ///
378    /// The returned path may point outside of the `base` directory. Use this
379    /// function only for displaying or testing purposes.
380    pub fn to_fs_path_unchecked(&self, base: &Path) -> PathBuf {
381        let mut result = PathBuf::with_capacity(base.as_os_str().len() + self.value.len() + 1);
382        result.push(base);
383        result.extend(self.components().map(RepoPathComponent::as_internal_str));
384        if result.as_os_str().is_empty() {
385            result.push(".");
386        }
387        result
388    }
389
390    pub fn is_root(&self) -> bool {
391        self.value.is_empty()
392    }
393
394    /// Returns true if the `base` is a prefix of this path.
395    pub fn starts_with(&self, base: &RepoPath) -> bool {
396        self.strip_prefix(base).is_some()
397    }
398
399    /// Returns the remaining path with the `base` path removed.
400    pub fn strip_prefix(&self, base: &RepoPath) -> Option<&RepoPath> {
401        if base.value.is_empty() {
402            Some(self)
403        } else {
404            let tail = self.value.strip_prefix(&base.value)?;
405            if tail.is_empty() {
406                Some(RepoPath::from_internal_string_unchecked(tail))
407            } else {
408                tail.strip_prefix('/')
409                    .map(RepoPath::from_internal_string_unchecked)
410            }
411        }
412    }
413
414    /// Returns the parent path without the base name component.
415    pub fn parent(&self) -> Option<&RepoPath> {
416        self.split().map(|(parent, _)| parent)
417    }
418
419    /// Splits this into the parent path and base name component.
420    pub fn split(&self) -> Option<(&RepoPath, &RepoPathComponent)> {
421        let mut components = self.components();
422        let basename = components.next_back()?;
423        Some((components.as_path(), basename))
424    }
425
426    pub fn components(&self) -> RepoPathComponentsIter<'_> {
427        RepoPathComponentsIter { value: &self.value }
428    }
429
430    pub fn join(&self, entry: &RepoPathComponent) -> RepoPathBuf {
431        let value = if self.value.is_empty() {
432            entry.as_internal_str().to_owned()
433        } else {
434            [&self.value, "/", entry.as_internal_str()].concat()
435        };
436        RepoPathBuf { value }
437    }
438}
439
440impl AsRef<RepoPath> for RepoPath {
441    fn as_ref(&self) -> &RepoPath {
442        self
443    }
444}
445
446impl AsRef<RepoPath> for RepoPathBuf {
447    fn as_ref(&self) -> &RepoPath {
448        self
449    }
450}
451
452impl Borrow<RepoPath> for RepoPathBuf {
453    fn borrow(&self) -> &RepoPath {
454        self
455    }
456}
457
458impl Deref for RepoPathBuf {
459    type Target = RepoPath;
460
461    fn deref(&self) -> &Self::Target {
462        RepoPath::from_internal_string_unchecked(&self.value)
463    }
464}
465
466impl ToOwned for RepoPath {
467    type Owned = RepoPathBuf;
468
469    fn to_owned(&self) -> Self::Owned {
470        let value = self.value.to_owned();
471        RepoPathBuf { value }
472    }
473
474    fn clone_into(&self, target: &mut Self::Owned) {
475        self.value.clone_into(&mut target.value);
476    }
477}
478
479impl Ord for RepoPath {
480    fn cmp(&self, other: &Self) -> Ordering {
481        // If there were leading/trailing slash, components-based Ord would
482        // disagree with str-based Eq.
483        debug_assert!(is_valid_repo_path_str(&self.value));
484        self.components().cmp(other.components())
485    }
486}
487
488impl Ord for RepoPathBuf {
489    fn cmp(&self, other: &Self) -> Ordering {
490        <RepoPath as Ord>::cmp(self, other)
491    }
492}
493
494impl PartialOrd for RepoPath {
495    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
496        Some(self.cmp(other))
497    }
498}
499
500impl PartialOrd for RepoPathBuf {
501    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
502        Some(self.cmp(other))
503    }
504}
505
506/// `RepoPath` contained invalid file/directory component such as `..`.
507#[derive(Clone, Debug, Eq, Error, PartialEq)]
508#[error(r#"Invalid repository path "{}""#, path.as_internal_file_string())]
509pub struct InvalidRepoPathError {
510    /// Path containing an error.
511    pub path: RepoPathBuf,
512    /// Source error.
513    pub source: InvalidRepoPathComponentError,
514}
515
516/// `RepoPath` component was invalid. (e.g. `..`)
517#[derive(Clone, Debug, Eq, Error, PartialEq)]
518#[error(r#"Invalid path component "{component}""#)]
519pub struct InvalidRepoPathComponentError {
520    pub component: Box<str>,
521}
522
523impl InvalidRepoPathComponentError {
524    /// Attaches the `path` that caused the error.
525    pub fn with_path(self, path: &RepoPath) -> InvalidRepoPathError {
526        InvalidRepoPathError {
527            path: path.to_owned(),
528            source: self,
529        }
530    }
531}
532
533#[derive(Clone, Debug, Eq, Error, PartialEq)]
534pub enum RelativePathParseError {
535    #[error(r#"Invalid component "{component}" in repo-relative path "{path}""#)]
536    InvalidComponent {
537        component: Box<str>,
538        path: Box<Path>,
539    },
540    #[error(r#"Not valid UTF-8 path "{path}""#)]
541    InvalidUtf8 { path: Box<Path> },
542}
543
544#[derive(Clone, Debug, Eq, Error, PartialEq)]
545#[error(r#"Path "{input}" is not in the repo "{base}""#)]
546pub struct FsPathParseError {
547    /// Repository or workspace root path relative to the `cwd`.
548    pub base: Box<Path>,
549    /// Input path without normalization.
550    pub input: Box<Path>,
551    /// Source error.
552    pub source: RelativePathParseError,
553}
554
555fn is_valid_repo_path_component_str(value: &str) -> bool {
556    !value.is_empty() && !value.contains('/')
557}
558
559fn is_valid_repo_path_str(value: &str) -> bool {
560    !value.starts_with('/') && !value.ends_with('/') && !value.contains("//")
561}
562
563/// An error from `RepoPathUiConverter::parse_file_path`.
564#[derive(Debug, Error)]
565pub enum UiPathParseError {
566    #[error(transparent)]
567    Fs(FsPathParseError),
568}
569
570/// Converts `RepoPath`s to and from plain strings as displayed to the user
571/// (e.g. relative to CWD).
572#[derive(Debug, Clone)]
573pub enum RepoPathUiConverter {
574    /// Variant for a local file system. Paths are interpreted relative to `cwd`
575    /// with the repo rooted in `base`.
576    ///
577    /// The `cwd` and `base` paths are supposed to be absolute and normalized in
578    /// the same manner.
579    Fs { cwd: PathBuf, base: PathBuf },
580    // TODO: Add a no-op variant that uses the internal `RepoPath` representation. Can be useful
581    // on a server.
582}
583
584impl RepoPathUiConverter {
585    /// Format a path for display in the UI.
586    pub fn format_file_path(&self, file: &RepoPath) -> String {
587        match self {
588            RepoPathUiConverter::Fs { cwd, base } => {
589                file_util::relative_path(cwd, &file.to_fs_path_unchecked(base))
590                    .to_str()
591                    .unwrap()
592                    .to_owned()
593            }
594        }
595    }
596
597    /// Format a copy from `source` to `target` for display in the UI by
598    /// extracting common components and producing something like
599    /// "common/prefix/{source => target}/common/suffix".
600    ///
601    /// If `source == target`, returns `format_file_path(source)`.
602    pub fn format_copied_path(&self, source: &RepoPath, target: &RepoPath) -> String {
603        if source == target {
604            return self.format_file_path(source);
605        }
606        let mut formatted = String::new();
607        match self {
608            RepoPathUiConverter::Fs { cwd, base } => {
609                let source_path = file_util::relative_path(cwd, &source.to_fs_path_unchecked(base));
610                let target_path = file_util::relative_path(cwd, &target.to_fs_path_unchecked(base));
611
612                let source_components = source_path.components().collect_vec();
613                let target_components = target_path.components().collect_vec();
614
615                let prefix_count = source_components
616                    .iter()
617                    .zip(target_components.iter())
618                    .take_while(|(source_component, target_component)| {
619                        source_component == target_component
620                    })
621                    .count()
622                    .min(source_components.len().saturating_sub(1))
623                    .min(target_components.len().saturating_sub(1));
624
625                let suffix_count = source_components
626                    .iter()
627                    .rev()
628                    .zip(target_components.iter().rev())
629                    .take_while(|(source_component, target_component)| {
630                        source_component == target_component
631                    })
632                    .count()
633                    .min(source_components.len().saturating_sub(1))
634                    .min(target_components.len().saturating_sub(1));
635
636                fn format_components(c: &[std::path::Component]) -> String {
637                    c.iter().collect::<PathBuf>().to_str().unwrap().to_owned()
638                }
639
640                if prefix_count > 0 {
641                    formatted.push_str(&format_components(&source_components[0..prefix_count]));
642                    formatted.push_str(std::path::MAIN_SEPARATOR_STR);
643                }
644                formatted.push('{');
645                formatted.push_str(&format_components(
646                    &source_components
647                        [prefix_count..(source_components.len() - suffix_count).max(prefix_count)],
648                ));
649                formatted.push_str(" => ");
650                formatted.push_str(&format_components(
651                    &target_components
652                        [prefix_count..(target_components.len() - suffix_count).max(prefix_count)],
653                ));
654                formatted.push('}');
655                if suffix_count > 0 {
656                    formatted.push_str(std::path::MAIN_SEPARATOR_STR);
657                    formatted.push_str(&format_components(
658                        &source_components[source_components.len() - suffix_count..],
659                    ));
660                }
661            }
662        }
663        formatted
664    }
665
666    /// Parses a path from the UI.
667    ///
668    /// It's up to the implementation whether absolute paths are allowed, and
669    /// where relative paths are interpreted as relative to.
670    pub fn parse_file_path(&self, input: &str) -> Result<RepoPathBuf, UiPathParseError> {
671        match self {
672            RepoPathUiConverter::Fs { cwd, base } => {
673                RepoPathBuf::parse_fs_path(cwd, base, input).map_err(UiPathParseError::Fs)
674            }
675        }
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use std::panic;
682
683    use assert_matches::assert_matches;
684    use itertools::Itertools as _;
685
686    use super::*;
687    use crate::tests::new_temp_dir;
688
689    fn repo_path(value: &str) -> &RepoPath {
690        RepoPath::from_internal_string(value).unwrap()
691    }
692
693    fn repo_path_component(value: &str) -> &RepoPathComponent {
694        RepoPathComponent::new(value).unwrap()
695    }
696
697    #[test]
698    fn test_is_root() {
699        assert!(RepoPath::root().is_root());
700        assert!(repo_path("").is_root());
701        assert!(!repo_path("foo").is_root());
702    }
703
704    #[test]
705    fn test_from_internal_string() {
706        let repo_path_buf = |value: &str| RepoPathBuf::from_internal_string(value).unwrap();
707        assert_eq!(repo_path_buf(""), RepoPathBuf::root());
708        assert!(panic::catch_unwind(|| repo_path_buf("/")).is_err());
709        assert!(panic::catch_unwind(|| repo_path_buf("/x")).is_err());
710        assert!(panic::catch_unwind(|| repo_path_buf("x/")).is_err());
711        assert!(panic::catch_unwind(|| repo_path_buf("x//y")).is_err());
712
713        assert_eq!(repo_path(""), RepoPath::root());
714        assert!(panic::catch_unwind(|| repo_path("/")).is_err());
715        assert!(panic::catch_unwind(|| repo_path("/x")).is_err());
716        assert!(panic::catch_unwind(|| repo_path("x/")).is_err());
717        assert!(panic::catch_unwind(|| repo_path("x//y")).is_err());
718    }
719
720    #[test]
721    fn test_as_internal_file_string() {
722        assert_eq!(RepoPath::root().as_internal_file_string(), "");
723        assert_eq!(repo_path("dir").as_internal_file_string(), "dir");
724        assert_eq!(repo_path("dir/file").as_internal_file_string(), "dir/file");
725    }
726
727    #[test]
728    fn test_to_internal_dir_string() {
729        assert_eq!(RepoPath::root().to_internal_dir_string(), "");
730        assert_eq!(repo_path("dir").to_internal_dir_string(), "dir/");
731        assert_eq!(repo_path("dir/file").to_internal_dir_string(), "dir/file/");
732    }
733
734    #[test]
735    fn test_starts_with() {
736        assert!(repo_path("").starts_with(repo_path("")));
737        assert!(repo_path("x").starts_with(repo_path("")));
738        assert!(!repo_path("").starts_with(repo_path("x")));
739
740        assert!(repo_path("x").starts_with(repo_path("x")));
741        assert!(repo_path("x/y").starts_with(repo_path("x")));
742        assert!(!repo_path("xy").starts_with(repo_path("x")));
743        assert!(!repo_path("x/y").starts_with(repo_path("y")));
744
745        assert!(repo_path("x/y").starts_with(repo_path("x/y")));
746        assert!(repo_path("x/y/z").starts_with(repo_path("x/y")));
747        assert!(!repo_path("x/yz").starts_with(repo_path("x/y")));
748        assert!(!repo_path("x").starts_with(repo_path("x/y")));
749        assert!(!repo_path("xy").starts_with(repo_path("x/y")));
750    }
751
752    #[test]
753    fn test_strip_prefix() {
754        assert_eq!(
755            repo_path("").strip_prefix(repo_path("")),
756            Some(repo_path(""))
757        );
758        assert_eq!(
759            repo_path("x").strip_prefix(repo_path("")),
760            Some(repo_path("x"))
761        );
762        assert_eq!(repo_path("").strip_prefix(repo_path("x")), None);
763
764        assert_eq!(
765            repo_path("x").strip_prefix(repo_path("x")),
766            Some(repo_path(""))
767        );
768        assert_eq!(
769            repo_path("x/y").strip_prefix(repo_path("x")),
770            Some(repo_path("y"))
771        );
772        assert_eq!(repo_path("xy").strip_prefix(repo_path("x")), None);
773        assert_eq!(repo_path("x/y").strip_prefix(repo_path("y")), None);
774
775        assert_eq!(
776            repo_path("x/y").strip_prefix(repo_path("x/y")),
777            Some(repo_path(""))
778        );
779        assert_eq!(
780            repo_path("x/y/z").strip_prefix(repo_path("x/y")),
781            Some(repo_path("z"))
782        );
783        assert_eq!(repo_path("x/yz").strip_prefix(repo_path("x/y")), None);
784        assert_eq!(repo_path("x").strip_prefix(repo_path("x/y")), None);
785        assert_eq!(repo_path("xy").strip_prefix(repo_path("x/y")), None);
786    }
787
788    #[test]
789    fn test_order() {
790        assert!(RepoPath::root() < repo_path("dir"));
791        assert!(repo_path("dir") < repo_path("dirx"));
792        // '#' < '/', but ["dir", "sub"] < ["dir#"]
793        assert!(repo_path("dir") < repo_path("dir#"));
794        assert!(repo_path("dir") < repo_path("dir/sub"));
795        assert!(repo_path("dir/sub") < repo_path("dir#"));
796
797        assert!(repo_path("abc") < repo_path("dir/file"));
798        assert!(repo_path("dir") < repo_path("dir/file"));
799        assert!(repo_path("dis") > repo_path("dir/file"));
800        assert!(repo_path("xyz") > repo_path("dir/file"));
801        assert!(repo_path("dir1/xyz") < repo_path("dir2/abc"));
802    }
803
804    #[test]
805    fn test_join() {
806        let root = RepoPath::root();
807        let dir = root.join(repo_path_component("dir"));
808        assert_eq!(dir.as_ref(), repo_path("dir"));
809        let subdir = dir.join(repo_path_component("subdir"));
810        assert_eq!(subdir.as_ref(), repo_path("dir/subdir"));
811        assert_eq!(
812            subdir.join(repo_path_component("file")).as_ref(),
813            repo_path("dir/subdir/file")
814        );
815    }
816
817    #[test]
818    fn test_parent() {
819        let root = RepoPath::root();
820        let dir_component = repo_path_component("dir");
821        let subdir_component = repo_path_component("subdir");
822
823        let dir = root.join(dir_component);
824        let subdir = dir.join(subdir_component);
825
826        assert_eq!(root.parent(), None);
827        assert_eq!(dir.parent(), Some(root));
828        assert_eq!(subdir.parent(), Some(dir.as_ref()));
829    }
830
831    #[test]
832    fn test_split() {
833        let root = RepoPath::root();
834        let dir_component = repo_path_component("dir");
835        let file_component = repo_path_component("file");
836
837        let dir = root.join(dir_component);
838        let file = dir.join(file_component);
839
840        assert_eq!(root.split(), None);
841        assert_eq!(dir.split(), Some((root, dir_component)));
842        assert_eq!(file.split(), Some((dir.as_ref(), file_component)));
843    }
844
845    #[test]
846    fn test_components() {
847        assert!(RepoPath::root().components().next().is_none());
848        assert_eq!(
849            repo_path("dir").components().collect_vec(),
850            vec![repo_path_component("dir")]
851        );
852        assert_eq!(
853            repo_path("dir/subdir").components().collect_vec(),
854            vec![repo_path_component("dir"), repo_path_component("subdir")]
855        );
856
857        // Iterates from back
858        assert!(RepoPath::root().components().next_back().is_none());
859        assert_eq!(
860            repo_path("dir").components().rev().collect_vec(),
861            vec![repo_path_component("dir")]
862        );
863        assert_eq!(
864            repo_path("dir/subdir").components().rev().collect_vec(),
865            vec![repo_path_component("subdir"), repo_path_component("dir")]
866        );
867    }
868
869    #[test]
870    fn test_to_fs_path() {
871        assert_eq!(
872            repo_path("").to_fs_path(Path::new("base/dir")).unwrap(),
873            Path::new("base/dir")
874        );
875        assert_eq!(
876            repo_path("").to_fs_path(Path::new("")).unwrap(),
877            Path::new(".")
878        );
879        assert_eq!(
880            repo_path("file").to_fs_path(Path::new("base/dir")).unwrap(),
881            Path::new("base/dir/file")
882        );
883        assert_eq!(
884            repo_path("some/deep/dir/file")
885                .to_fs_path(Path::new("base/dir"))
886                .unwrap(),
887            Path::new("base/dir/some/deep/dir/file")
888        );
889        assert_eq!(
890            repo_path("dir/file").to_fs_path(Path::new("")).unwrap(),
891            Path::new("dir/file")
892        );
893
894        // Current/parent dir component
895        assert!(repo_path(".").to_fs_path(Path::new("base")).is_err());
896        assert!(repo_path("..").to_fs_path(Path::new("base")).is_err());
897        assert!(repo_path("dir/../file")
898            .to_fs_path(Path::new("base"))
899            .is_err());
900        assert!(repo_path("./file").to_fs_path(Path::new("base")).is_err());
901        assert!(repo_path("file/.").to_fs_path(Path::new("base")).is_err());
902        assert!(repo_path("../file").to_fs_path(Path::new("base")).is_err());
903        assert!(repo_path("file/..").to_fs_path(Path::new("base")).is_err());
904
905        // Empty component (which is invalid as a repo path)
906        assert!(RepoPath::from_internal_string_unchecked("/")
907            .to_fs_path(Path::new("base"))
908            .is_err());
909        assert_eq!(
910            // Iterator omits empty component after "/", which is fine so long
911            // as the returned path doesn't escape.
912            RepoPath::from_internal_string_unchecked("a/")
913                .to_fs_path(Path::new("base"))
914                .unwrap(),
915            Path::new("base/a")
916        );
917        assert!(RepoPath::from_internal_string_unchecked("/b")
918            .to_fs_path(Path::new("base"))
919            .is_err());
920        assert!(RepoPath::from_internal_string_unchecked("a//b")
921            .to_fs_path(Path::new("base"))
922            .is_err());
923
924        // Component containing slash (simulating Windows path separator)
925        assert!(RepoPathComponent::new_unchecked("wind/ows")
926            .to_fs_name()
927            .is_err());
928        assert!(RepoPathComponent::new_unchecked("./file")
929            .to_fs_name()
930            .is_err());
931        assert!(RepoPathComponent::new_unchecked("file/.")
932            .to_fs_name()
933            .is_err());
934        assert!(RepoPathComponent::new_unchecked("/").to_fs_name().is_err());
935
936        // Windows path separator and drive letter
937        if cfg!(windows) {
938            assert!(repo_path(r#"wind\ows"#)
939                .to_fs_path(Path::new("base"))
940                .is_err());
941            assert!(repo_path(r#".\file"#)
942                .to_fs_path(Path::new("base"))
943                .is_err());
944            assert!(repo_path(r#"file\."#)
945                .to_fs_path(Path::new("base"))
946                .is_err());
947            assert!(repo_path(r#"c:/foo"#)
948                .to_fs_path(Path::new("base"))
949                .is_err());
950        }
951    }
952
953    #[test]
954    fn test_to_fs_path_unchecked() {
955        assert_eq!(
956            repo_path("").to_fs_path_unchecked(Path::new("base/dir")),
957            Path::new("base/dir")
958        );
959        assert_eq!(
960            repo_path("").to_fs_path_unchecked(Path::new("")),
961            Path::new(".")
962        );
963        assert_eq!(
964            repo_path("file").to_fs_path_unchecked(Path::new("base/dir")),
965            Path::new("base/dir/file")
966        );
967        assert_eq!(
968            repo_path("some/deep/dir/file").to_fs_path_unchecked(Path::new("base/dir")),
969            Path::new("base/dir/some/deep/dir/file")
970        );
971        assert_eq!(
972            repo_path("dir/file").to_fs_path_unchecked(Path::new("")),
973            Path::new("dir/file")
974        );
975    }
976
977    #[test]
978    fn parse_fs_path_wc_in_cwd() {
979        let temp_dir = new_temp_dir();
980        let cwd_path = temp_dir.path().join("repo");
981        let wc_path = &cwd_path;
982
983        assert_eq!(
984            RepoPathBuf::parse_fs_path(&cwd_path, wc_path, "").as_deref(),
985            Ok(RepoPath::root())
986        );
987        assert_eq!(
988            RepoPathBuf::parse_fs_path(&cwd_path, wc_path, ".").as_deref(),
989            Ok(RepoPath::root())
990        );
991        assert_eq!(
992            RepoPathBuf::parse_fs_path(&cwd_path, wc_path, "file").as_deref(),
993            Ok(repo_path("file"))
994        );
995        // Both slash and the platform's separator are allowed
996        assert_eq!(
997            RepoPathBuf::parse_fs_path(
998                &cwd_path,
999                wc_path,
1000                format!("dir{}file", std::path::MAIN_SEPARATOR)
1001            )
1002            .as_deref(),
1003            Ok(repo_path("dir/file"))
1004        );
1005        assert_eq!(
1006            RepoPathBuf::parse_fs_path(&cwd_path, wc_path, "dir/file").as_deref(),
1007            Ok(repo_path("dir/file"))
1008        );
1009        assert_matches!(
1010            RepoPathBuf::parse_fs_path(&cwd_path, wc_path, ".."),
1011            Err(FsPathParseError {
1012                source: RelativePathParseError::InvalidComponent { .. },
1013                ..
1014            })
1015        );
1016        assert_eq!(
1017            RepoPathBuf::parse_fs_path(&cwd_path, &cwd_path, "../repo").as_deref(),
1018            Ok(RepoPath::root())
1019        );
1020        assert_eq!(
1021            RepoPathBuf::parse_fs_path(&cwd_path, &cwd_path, "../repo/file").as_deref(),
1022            Ok(repo_path("file"))
1023        );
1024        // Input may be absolute path with ".."
1025        assert_eq!(
1026            RepoPathBuf::parse_fs_path(
1027                &cwd_path,
1028                &cwd_path,
1029                cwd_path.join("../repo").to_str().unwrap()
1030            )
1031            .as_deref(),
1032            Ok(RepoPath::root())
1033        );
1034    }
1035
1036    #[test]
1037    fn parse_fs_path_wc_in_cwd_parent() {
1038        let temp_dir = new_temp_dir();
1039        let cwd_path = temp_dir.path().join("dir");
1040        let wc_path = cwd_path.parent().unwrap().to_path_buf();
1041
1042        assert_eq!(
1043            RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "").as_deref(),
1044            Ok(repo_path("dir"))
1045        );
1046        assert_eq!(
1047            RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, ".").as_deref(),
1048            Ok(repo_path("dir"))
1049        );
1050        assert_eq!(
1051            RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "file").as_deref(),
1052            Ok(repo_path("dir/file"))
1053        );
1054        assert_eq!(
1055            RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "subdir/file").as_deref(),
1056            Ok(repo_path("dir/subdir/file"))
1057        );
1058        assert_eq!(
1059            RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "..").as_deref(),
1060            Ok(RepoPath::root())
1061        );
1062        assert_matches!(
1063            RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "../.."),
1064            Err(FsPathParseError {
1065                source: RelativePathParseError::InvalidComponent { .. },
1066                ..
1067            })
1068        );
1069        assert_eq!(
1070            RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "../other-dir/file").as_deref(),
1071            Ok(repo_path("other-dir/file"))
1072        );
1073    }
1074
1075    #[test]
1076    fn parse_fs_path_wc_in_cwd_child() {
1077        let temp_dir = new_temp_dir();
1078        let cwd_path = temp_dir.path().join("cwd");
1079        let wc_path = cwd_path.join("repo");
1080
1081        assert_matches!(
1082            RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, ""),
1083            Err(FsPathParseError {
1084                source: RelativePathParseError::InvalidComponent { .. },
1085                ..
1086            })
1087        );
1088        assert_matches!(
1089            RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "not-repo"),
1090            Err(FsPathParseError {
1091                source: RelativePathParseError::InvalidComponent { .. },
1092                ..
1093            })
1094        );
1095        assert_eq!(
1096            RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "repo").as_deref(),
1097            Ok(RepoPath::root())
1098        );
1099        assert_eq!(
1100            RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "repo/file").as_deref(),
1101            Ok(repo_path("file"))
1102        );
1103        assert_eq!(
1104            RepoPathBuf::parse_fs_path(&cwd_path, &wc_path, "repo/dir/file").as_deref(),
1105            Ok(repo_path("dir/file"))
1106        );
1107    }
1108
1109    #[test]
1110    fn test_format_copied_path() {
1111        let ui = RepoPathUiConverter::Fs {
1112            cwd: PathBuf::from("."),
1113            base: PathBuf::from("."),
1114        };
1115
1116        let format = |before, after| {
1117            ui.format_copied_path(repo_path(before), repo_path(after))
1118                .replace('\\', "/")
1119        };
1120
1121        assert_eq!(format("one/two/three", "one/two/three"), "one/two/three");
1122        assert_eq!(format("one/two", "one/two/three"), "one/{two => two/three}");
1123        assert_eq!(format("one/two", "zero/one/two"), "{one => zero/one}/two");
1124        assert_eq!(format("one/two/three", "one/two"), "one/{two/three => two}");
1125        assert_eq!(format("zero/one/two", "one/two"), "{zero/one => one}/two");
1126        assert_eq!(
1127            format("one/two", "one/two/three/one/two"),
1128            "one/{ => two/three/one}/two"
1129        );
1130
1131        assert_eq!(format("two/three", "four/three"), "{two => four}/three");
1132        assert_eq!(
1133            format("one/two/three", "one/four/three"),
1134            "one/{two => four}/three"
1135        );
1136        assert_eq!(format("one/two/three", "one/three"), "one/{two => }/three");
1137        assert_eq!(format("one/two", "one/four"), "one/{two => four}");
1138        assert_eq!(format("two", "four"), "{two => four}");
1139        assert_eq!(format("file1", "file2"), "{file1 => file2}");
1140        assert_eq!(format("file-1", "file-2"), "{file-1 => file-2}");
1141    }
1142}