Skip to main content

jj_lib/
ui_path.rs

1// Copyright 2026 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//! Utilities for converting between `RepoPath`s and plain strings as displayed
16//! to the user (e.g. relative to CWD).
17
18use std::iter;
19use std::path::Path;
20use std::path::PathBuf;
21
22use thiserror::Error;
23
24use crate::file_util;
25use crate::merge::Diff;
26use crate::repo_path::RelativePathParseError;
27use crate::repo_path::RepoPath;
28use crate::repo_path::RepoPathBuf;
29
30/// An error which occurs when we're parsing paths.
31#[derive(Clone, Debug, Eq, Error, PartialEq)]
32#[error(r#"Path "{input}" is not in the repo "{base}""#)]
33pub struct FsPathParseError {
34    /// Repository or workspace root path relative to the `cwd`.
35    pub base: Box<Path>,
36    /// Input path without normalization.
37    pub input: Box<Path>,
38    /// Source error.
39    pub source: RelativePathParseError,
40}
41
42/// An error from `RepoPathUiConverter::parse_file_path`.
43#[derive(Debug, Error)]
44pub enum UiPathParseError {
45    /// Failure to parse a path a relative path inside the repo.
46    #[error(transparent)]
47    Fs(FsPathParseError),
48}
49
50/// Converts `RepoPath`s to and from plain strings as displayed to the user
51/// (e.g. relative to CWD).
52#[derive(Debug, Clone)]
53pub enum RepoPathUiConverter {
54    /// Variant for a local file system. Paths are interpreted relative to `cwd`
55    /// with the repo rooted in `base`.
56    ///
57    /// The `cwd` and `base` paths are supposed to be absolute and normalized in
58    /// the same manner.
59    Fs {
60        /// The directory to which relative paths are interpreted.
61        cwd: PathBuf,
62        /// The repository root path.
63        base: PathBuf,
64    },
65    // TODO: Add a no-op variant that uses the internal `RepoPath` representation. Can be useful
66    // on a server.
67}
68
69impl RepoPathUiConverter {
70    /// Format a path for display in the UI.
71    pub fn format_file_path(&self, file: &RepoPath) -> String {
72        match self {
73            Self::Fs { cwd, base } => {
74                file_util::relative_path(cwd, &file.to_fs_path_unchecked(base))
75                    .display()
76                    .to_string()
77            }
78        }
79    }
80
81    /// Format a copy from `before` to `after` for display in the UI by
82    /// extracting common components and producing something like
83    /// "common/prefix/{before => after}/common/suffix".
84    ///
85    /// If `before == after`, this is equivalent to `format_file_path()`.
86    pub fn format_copied_path(&self, paths: Diff<&RepoPath>) -> String {
87        match self {
88            Self::Fs { .. } => {
89                let paths = paths.map(|path| self.format_file_path(path));
90                collapse_copied_path(paths.as_deref(), std::path::MAIN_SEPARATOR)
91            }
92        }
93    }
94
95    /// Parses a path from the UI.
96    ///
97    /// It's up to the implementation whether absolute paths are allowed, and
98    /// where relative paths are interpreted as relative to.
99    pub fn parse_file_path(&self, input: &str) -> Result<RepoPathBuf, UiPathParseError> {
100        match self {
101            Self::Fs { cwd, base } => parse_fs_path(cwd, base, input).map_err(UiPathParseError::Fs),
102        }
103    }
104}
105
106fn collapse_copied_path(paths: Diff<&str>, separator: char) -> String {
107    // The last component should never match middle components. This is ensured
108    // by including trailing separators. e.g. ("a/b", "a/b/x") => ("a/", _)
109    let components = paths.map(|path| path.split_inclusive(separator));
110    let prefix_len: usize = iter::zip(components.before, components.after)
111        .take_while(|(before, after)| before == after)
112        .map(|(_, after)| after.len())
113        .sum();
114    if paths.before.len() == prefix_len && paths.after.len() == prefix_len {
115        return paths.after.to_owned();
116    }
117
118    // The first component should never match middle components, but the first
119    // uncommon middle component can. e.g. ("a/b", "x/a/b") => ("", "/b"),
120    // ("a/b", "a/x/b") => ("a/", "/b")
121    let components = paths.map(|path| {
122        let mut remainder = &path[prefix_len.saturating_sub(1)..];
123        iter::from_fn(move || {
124            let pos = remainder.rfind(separator)?;
125            let (prefix, last) = remainder.split_at(pos);
126            remainder = prefix;
127            Some(last)
128        })
129    });
130    let suffix_len: usize = iter::zip(components.before, components.after)
131        .take_while(|(before, after)| before == after)
132        .map(|(_, after)| after.len())
133        .sum();
134
135    // Middle range may be invalid (start > end) because the same separator char
136    // can be distributed to both common prefix and suffix. e.g.
137    // ("a/b", "a/x/b") == ("a//b", "a/x/b") => ("a/", "/b")
138    let middle = paths.map(|path| path.get(prefix_len..path.len() - suffix_len).unwrap_or(""));
139
140    let mut collapsed = String::new();
141    collapsed.push_str(&paths.after[..prefix_len]);
142    collapsed.push('{');
143    collapsed.push_str(middle.before);
144    collapsed.push_str(" => ");
145    collapsed.push_str(middle.after);
146    collapsed.push('}');
147    collapsed.push_str(&paths.after[paths.after.len() - suffix_len..]);
148    collapsed
149}
150
151/// Parses an `input` path into a `RepoPathBuf` relative to `base`.
152///
153/// The `cwd` and `base` paths are supposed to be absolute and normalized in
154/// the same manner. The `input` path may be either relative to `cwd` or
155/// absolute.
156pub fn parse_fs_path(
157    cwd: &Path,
158    base: &Path,
159    input: impl AsRef<Path>,
160) -> Result<RepoPathBuf, FsPathParseError> {
161    let input = input.as_ref();
162    let abs_input_path = file_util::normalize_path(&cwd.join(input));
163    let repo_relative_path = file_util::relative_path(base, &abs_input_path);
164    RepoPathBuf::from_relative_path(repo_relative_path).map_err(|source| FsPathParseError {
165        base: file_util::relative_path(cwd, base).into(),
166        input: input.into(),
167        source,
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use assert_matches::assert_matches;
174
175    use super::*;
176    use crate::tests::new_temp_dir;
177
178    fn repo_path(value: &str) -> &RepoPath {
179        RepoPath::from_internal_string(value).unwrap()
180    }
181
182    #[test]
183    fn test_format_copied_path() {
184        let ui = RepoPathUiConverter::Fs {
185            cwd: PathBuf::from("."),
186            base: PathBuf::from("."),
187        };
188
189        let format = |before, after| {
190            ui.format_copied_path(Diff::new(repo_path(before), repo_path(after)))
191                .replace('\\', "/")
192        };
193
194        assert_eq!(format("one/two/three", "one/two/three"), "one/two/three");
195        assert_eq!(format("one/two", "one/two/three"), "one/{two => two/three}");
196        assert_eq!(format("one/two", "zero/one/two"), "{one => zero/one}/two");
197        assert_eq!(format("one/two/three", "one/two"), "one/{two/three => two}");
198        assert_eq!(format("zero/one/two", "one/two"), "{zero/one => one}/two");
199        assert_eq!(
200            format("one/two", "one/two/three/one/two"),
201            "one/{ => two/three/one}/two"
202        );
203
204        assert_eq!(format("two/three", "four/three"), "{two => four}/three");
205        assert_eq!(
206            format("one/two/three", "one/four/three"),
207            "one/{two => four}/three"
208        );
209        assert_eq!(format("one/two/three", "one/three"), "one/{two => }/three");
210        assert_eq!(format("one/two", "one/four"), "one/{two => four}");
211        assert_eq!(format("two", "four"), "{two => four}");
212        assert_eq!(format("file1", "file2"), "{file1 => file2}");
213        assert_eq!(format("file-1", "file-2"), "{file-1 => file-2}");
214        assert_eq!(
215            format("x/something/something/2to1.txt", "x/something/2to1.txt"),
216            "x/something/{something => }/2to1.txt"
217        );
218        assert_eq!(
219            format("x/something/1to2.txt", "x/something/something/1to2.txt"),
220            "x/something/{ => something}/1to2.txt"
221        );
222    }
223
224    #[test]
225    fn parse_fs_path_wc_in_cwd() {
226        let temp_dir = new_temp_dir();
227        let cwd_path = temp_dir.path().join("repo");
228        let wc_path = &cwd_path;
229
230        assert_eq!(
231            parse_fs_path(&cwd_path, wc_path, "").as_deref(),
232            Ok(RepoPath::root())
233        );
234        assert_eq!(
235            parse_fs_path(&cwd_path, wc_path, ".").as_deref(),
236            Ok(RepoPath::root())
237        );
238        assert_eq!(
239            parse_fs_path(&cwd_path, wc_path, "file").as_deref(),
240            Ok(repo_path("file"))
241        );
242        // Both slash and the platform's separator are allowed
243        assert_eq!(
244            parse_fs_path(
245                &cwd_path,
246                wc_path,
247                format!("dir{}file", std::path::MAIN_SEPARATOR)
248            )
249            .as_deref(),
250            Ok(repo_path("dir/file"))
251        );
252        assert_eq!(
253            parse_fs_path(&cwd_path, wc_path, "dir/file").as_deref(),
254            Ok(repo_path("dir/file"))
255        );
256        assert_matches!(
257            parse_fs_path(&cwd_path, wc_path, ".."),
258            Err(FsPathParseError {
259                source: RelativePathParseError::InvalidComponent { .. },
260                ..
261            })
262        );
263        assert_eq!(
264            parse_fs_path(&cwd_path, &cwd_path, "../repo").as_deref(),
265            Ok(RepoPath::root())
266        );
267        assert_eq!(
268            parse_fs_path(&cwd_path, &cwd_path, "../repo/file").as_deref(),
269            Ok(repo_path("file"))
270        );
271        // Input may be absolute path with ".."
272        assert_eq!(
273            parse_fs_path(
274                &cwd_path,
275                &cwd_path,
276                cwd_path.join("../repo").to_str().unwrap()
277            )
278            .as_deref(),
279            Ok(RepoPath::root())
280        );
281    }
282
283    #[test]
284    fn parse_fs_path_wc_in_cwd_parent() {
285        let temp_dir = new_temp_dir();
286        let cwd_path = temp_dir.path().join("dir");
287        let wc_path = cwd_path.parent().unwrap().to_path_buf();
288
289        assert_eq!(
290            parse_fs_path(&cwd_path, &wc_path, "").as_deref(),
291            Ok(repo_path("dir"))
292        );
293        assert_eq!(
294            parse_fs_path(&cwd_path, &wc_path, ".").as_deref(),
295            Ok(repo_path("dir"))
296        );
297        assert_eq!(
298            parse_fs_path(&cwd_path, &wc_path, "file").as_deref(),
299            Ok(repo_path("dir/file"))
300        );
301        assert_eq!(
302            parse_fs_path(&cwd_path, &wc_path, "subdir/file").as_deref(),
303            Ok(repo_path("dir/subdir/file"))
304        );
305        assert_eq!(
306            parse_fs_path(&cwd_path, &wc_path, "..").as_deref(),
307            Ok(RepoPath::root())
308        );
309        assert_matches!(
310            parse_fs_path(&cwd_path, &wc_path, "../.."),
311            Err(FsPathParseError {
312                source: RelativePathParseError::InvalidComponent { .. },
313                ..
314            })
315        );
316        assert_eq!(
317            parse_fs_path(&cwd_path, &wc_path, "../other-dir/file").as_deref(),
318            Ok(repo_path("other-dir/file"))
319        );
320    }
321
322    #[test]
323    fn parse_fs_path_wc_in_cwd_child() {
324        let temp_dir = new_temp_dir();
325        let cwd_path = temp_dir.path().join("cwd");
326        let wc_path = cwd_path.join("repo");
327
328        assert_matches!(
329            parse_fs_path(&cwd_path, &wc_path, ""),
330            Err(FsPathParseError {
331                source: RelativePathParseError::InvalidComponent { .. },
332                ..
333            })
334        );
335        assert_matches!(
336            parse_fs_path(&cwd_path, &wc_path, "not-repo"),
337            Err(FsPathParseError {
338                source: RelativePathParseError::InvalidComponent { .. },
339                ..
340            })
341        );
342        assert_eq!(
343            parse_fs_path(&cwd_path, &wc_path, "repo").as_deref(),
344            Ok(RepoPath::root())
345        );
346        assert_eq!(
347            parse_fs_path(&cwd_path, &wc_path, "repo/file").as_deref(),
348            Ok(repo_path("file"))
349        );
350        assert_eq!(
351            parse_fs_path(&cwd_path, &wc_path, "repo/dir/file").as_deref(),
352            Ok(repo_path("dir/file"))
353        );
354    }
355}