Skip to main content

ccf_gpui_widgets/utils/
path.rs

1//! Path utilities
2//!
3//! Provides path canonicalization and validation helpers for file/directory pickers.
4//!
5//! # Example
6//!
7//! ```ignore
8//! use ccf_gpui_widgets::utils::path::{parse_path, expand_tilde};
9//!
10//! // Parse and canonicalize a path
11//! let info = parse_path("~/Documents/output.txt");
12//! if info.fully_exists() {
13//!     println!("File exists at: {}", info.full_path_string());
14//! } else {
15//!     println!("Existing portion: {:?}", info.existing_canonical);
16//!     println!("Non-existing suffix: {:?}", info.non_existing_suffix);
17//! }
18//! ```
19
20use std::path::{Path, PathBuf};
21
22/// Result of parsing and canonicalizing a user-provided path
23#[derive(Debug, Clone)]
24pub struct PathInfo {
25    /// The canonicalized portion that exists on disk
26    pub existing_canonical: PathBuf,
27    /// The non-existing suffix (empty if entire path exists)
28    pub non_existing_suffix: PathBuf,
29    /// The full path (canonical + suffix)
30    pub full_path: PathBuf,
31}
32
33impl PathInfo {
34    /// Returns true if the entire path exists
35    pub fn fully_exists(&self) -> bool {
36        self.non_existing_suffix.as_os_str().is_empty()
37    }
38
39    /// Returns the full path as a string
40    pub fn full_path_string(&self) -> String {
41        self.full_path.to_string_lossy().to_string()
42    }
43
44    /// Create an empty PathInfo
45    pub fn empty() -> Self {
46        Self {
47            existing_canonical: PathBuf::new(),
48            non_existing_suffix: PathBuf::new(),
49            full_path: PathBuf::new(),
50        }
51    }
52}
53
54impl Default for PathInfo {
55    fn default() -> Self {
56        Self::empty()
57    }
58}
59
60/// Parse and canonicalize a user-provided path (which may be relative or non-canonical)
61///
62/// Returns a PathInfo struct containing:
63/// - existing_canonical: The longest prefix that exists and can be canonicalized
64/// - non_existing_suffix: Any remaining path components that don't exist
65/// - full_path: The complete reconstructed path
66///
67/// # Examples
68///
69/// ```ignore
70/// // Existing file
71/// let info = parse_path("/Users/foo/file.txt");
72/// assert!(info.fully_exists());
73///
74/// // Non-existing output file
75/// let info = parse_path("/Users/foo/output.txt");
76/// // info.existing_canonical = /Users/foo (if it exists)
77/// // info.non_existing_suffix = output.txt
78///
79/// // Relative path
80/// let info = parse_path("../data/file.txt");
81/// // Resolves relative to current directory
82/// ```
83pub fn parse_path(input: &str) -> PathInfo {
84    if input.is_empty() {
85        return PathInfo::empty();
86    }
87
88    // Expand ~ to home directory
89    let expanded = expand_tilde(input);
90    let path = Path::new(&expanded);
91
92    // Convert to absolute path if relative
93    let absolute = if path.is_absolute() {
94        path.to_path_buf()
95    } else {
96        // Resolve relative to current directory
97        std::env::current_dir()
98            .map(|cwd| cwd.join(path))
99            .unwrap_or_else(|err| {
100                log::warn!("Could not determine current directory: {}. Using relative path.", err);
101                path.to_path_buf()
102            })
103    };
104
105    // Find the longest existing prefix
106    let (existing, suffix) = find_existing_prefix(&absolute);
107
108    // Try to canonicalize the existing portion
109    let canonical = existing
110        .canonicalize()
111        .unwrap_or_else(|err| {
112            log::warn!("Could not canonicalize path {:?}: {}", existing, err);
113            existing.clone()
114        });
115
116    // Reconstruct full path
117    let full = if suffix.as_os_str().is_empty() {
118        canonical.clone()
119    } else {
120        canonical.join(&suffix)
121    };
122
123    PathInfo {
124        existing_canonical: canonical,
125        non_existing_suffix: suffix,
126        full_path: full,
127    }
128}
129
130/// Expand tilde (~) to home directory
131#[cfg(feature = "file-picker")]
132pub fn expand_tilde(path: &str) -> String {
133    if path.starts_with("~/") || path == "~" {
134        if let Some(home) = dirs::home_dir() {
135            if path == "~" {
136                return home.to_string_lossy().to_string();
137            } else {
138                return home.join(&path[2..]).to_string_lossy().to_string();
139            }
140        }
141    }
142    path.to_string()
143}
144
145/// Expand tilde (~) to home directory (fallback when dirs is not available)
146#[cfg(not(feature = "file-picker"))]
147pub fn expand_tilde(path: &str) -> String {
148    if path.starts_with("~/") || path == "~" {
149        if let Ok(home) = std::env::var("HOME") {
150            if path == "~" {
151                return home;
152            } else {
153                return format!("{}/{}", home, &path[2..]);
154            }
155        }
156    }
157    path.to_string()
158}
159
160/// Build a path from components in reverse order
161fn build_suffix(components: &[std::ffi::OsString]) -> PathBuf {
162    components
163        .iter()
164        .rev()
165        .fold(PathBuf::new(), |acc, comp| acc.join(comp))
166}
167
168/// Find the longest prefix of the path that exists on disk
169fn find_existing_prefix(path: &Path) -> (PathBuf, PathBuf) {
170    let mut current = path.to_path_buf();
171    let mut suffix_components = Vec::new();
172
173    loop {
174        if current.exists() {
175            return (current, build_suffix(&suffix_components));
176        }
177
178        match current.file_name() {
179            Some(component) => {
180                suffix_components.push(component.to_os_string());
181                current = current
182                    .parent()
183                    .map(|p| p.to_path_buf())
184                    .unwrap_or_else(|| PathBuf::from("/"));
185            }
186            None => {
187                return (PathBuf::from("/"), build_suffix(&suffix_components));
188            }
189        }
190
191        if current.as_os_str().is_empty() || current == Path::new("/") {
192            return (current, build_suffix(&suffix_components));
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_expand_tilde() {
203        // Test that ~ expands to something (exact value depends on environment)
204        let expanded = expand_tilde("~");
205        assert!(!expanded.is_empty());
206        assert!(!expanded.starts_with('~') || expanded == "~"); // Either expanded or unchanged
207
208        let with_path = expand_tilde("~/Documents");
209        assert!(with_path.ends_with("/Documents") || with_path == "~/Documents");
210    }
211
212    #[test]
213    fn test_parse_existing_path() {
214        let temp = std::env::temp_dir();
215        let info = parse_path(temp.to_str().unwrap());
216        assert!(info.fully_exists());
217        assert_eq!(info.non_existing_suffix, PathBuf::new());
218    }
219
220    #[test]
221    fn test_parse_non_existing_file() {
222        let temp = std::env::temp_dir();
223        let non_existing = temp.join("this_file_should_not_exist_12345.txt");
224        let info = parse_path(non_existing.to_str().unwrap());
225
226        assert!(!info.fully_exists());
227        assert_eq!(info.non_existing_suffix, PathBuf::from("this_file_should_not_exist_12345.txt"));
228    }
229
230    #[test]
231    fn test_empty_path() {
232        let info = parse_path("");
233        assert!(info.full_path.as_os_str().is_empty());
234    }
235}