Skip to main content

strop_workspace/
resource.rs

1//! One resolved resource path on one filesystem: the identity routing,
2//! diagnostics, bindings and navigation share (0042). Supersedes
3//! strop-lsp's `DocPath`; the serialized field accepts the legacy
4//! `target` name so records written before the move still decode.
5
6use std::path::PathBuf;
7
8use crate::addr::RemoteEndpoint;
9use crate::filesystem::Filesystem;
10
11/// One resolved path on one filesystem namespace. Always resolved: an
12/// unresolved user entry (a `~` home query) is a
13/// [`crate::addr::RemoteLocation`], never a `ResourceLocation`.
14#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
15pub struct ResourceLocation {
16    #[serde(alias = "target")]
17    pub filesystem: Filesystem,
18    #[serde(with = "strop_core::path_serde")]
19    pub path: PathBuf,
20}
21
22impl ResourceLocation {
23    /// Explicit, lossless clipboard/completion spelling. Display labels are not
24    /// used as an input codec, especially for non-UTF-8 names and container IDs.
25    pub fn uri(&self) -> Result<String, crate::AddressError> {
26        if !self.path.is_absolute() {
27            return Err(crate::AddressError::RelativePath);
28        }
29        if crate::addr::uri::path_bytes(&self.path).contains(&0) {
30            return Err(crate::AddressError::NulInPath);
31        }
32        let mut value = match &self.filesystem {
33            Filesystem::Local => "file://".to_owned(),
34            Filesystem::Remote(endpoint) => {
35                return crate::RemoteFile::from_path(endpoint.clone(), self.path.clone())
36                    .map(|file| file.to_string())
37            }
38            Filesystem::Container(id) => format!("container:{id}"),
39        };
40        crate::addr::uri::push_escaped(&mut value, crate::addr::uri::path_bytes(&self.path));
41        Ok(value)
42    }
43
44    pub fn parse_uri(value: &str) -> Result<Self, crate::AddressError> {
45        if value.starts_with("ssh://") {
46            let file = crate::RemoteFile::parse(value)?;
47            return Ok(Self::remote(
48                file.endpoint().clone(),
49                file.path().to_path_buf(),
50            ));
51        }
52        let (filesystem, raw_path) = if let Some(rest) = value.strip_prefix("file://") {
53            let path = if rest.starts_with('/') {
54                rest
55            } else {
56                rest.strip_prefix("localhost")
57                    .filter(|path| path.starts_with('/'))
58                    .ok_or(crate::AddressError::NonLocalFileAuthority)?
59            };
60            (Filesystem::Local, path)
61        } else if let Some(rest) = value.strip_prefix("container:") {
62            let slash = rest
63                .find('/')
64                .ok_or(crate::AddressError::InvalidContainerLocation)?;
65            let id = crate::ContainerId::canonical(rest[..slash].to_owned())
66                .map_err(|_| crate::AddressError::InvalidContainerLocation)?;
67            (Filesystem::Container(id), &rest[slash..])
68        } else {
69            return Err(crate::AddressError::UnsupportedResourceUri);
70        };
71        let path = crate::addr::uri::decode_path(raw_path)?;
72        if !path.is_absolute() {
73            return Err(crate::AddressError::RelativePath);
74        }
75        Ok(Self { filesystem, path })
76    }
77    pub fn local(path: PathBuf) -> Self {
78        Self {
79            filesystem: Filesystem::Local,
80            path,
81        }
82    }
83
84    pub fn remote(endpoint: RemoteEndpoint, path: PathBuf) -> Self {
85        Self {
86            filesystem: Filesystem::Remote(endpoint),
87            path,
88        }
89    }
90
91    /// Map an exact resource or descendant through a same-namespace relocation.
92    /// Empty relative paths must not append a slash to a regular-file binding.
93    pub fn relocated(&self, source: &Self, destination: &Self) -> Option<Self> {
94        if self.filesystem != source.filesystem || source.filesystem != destination.filesystem {
95            return None;
96        }
97        let relative = self.path.strip_prefix(&source.path).ok()?;
98        Some(Self {
99            filesystem: self.filesystem.clone(),
100            path: if relative.as_os_str().is_empty() {
101                destination.path.clone()
102            } else {
103                destination.path.join(relative)
104            },
105        })
106    }
107
108    /// Modeline/trace form: the bare path locally, `endpoint + path`
109    /// for a remote resource — never an ambiguous local-looking path.
110    pub fn label(&self) -> String {
111        let path = crate::directory::display_path(&self.path);
112        match &self.filesystem {
113            Filesystem::Local => path,
114            other => format!("{}{path}", other.label()),
115        }
116    }
117
118    /// The local path, only when this resource is on the local disk.
119    /// There is no other way to read the path as a local one.
120    pub fn local_path(&self) -> Option<&std::path::Path> {
121        match &self.filesystem {
122            Filesystem::Local => Some(&self.path),
123            _ => None,
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn decodes_the_legacy_docpath_shape() {
134        // Written by strop-lsp's DocPath (0.19.1): field name `target`.
135        let legacy = serde_json::json!({
136            "target": "Local",
137            "path": "/workspace/a.rs",
138        });
139        let location: ResourceLocation = serde_json::from_value(legacy).unwrap();
140        assert_eq!(location.filesystem, Filesystem::Local);
141        assert_eq!(location.path, PathBuf::from("/workspace/a.rs"));
142        let endpoint = RemoteEndpoint::parse("ssh://user@example.com:2222").unwrap();
143        let legacy = serde_json::json!({
144            "target": { "Remote": endpoint },
145            "path": "/var/log/app.log",
146        });
147        let location: ResourceLocation = serde_json::from_value(legacy).unwrap();
148        assert_eq!(location.filesystem, Filesystem::Remote(endpoint));
149    }
150
151    #[test]
152    fn remote_resources_never_expose_a_local_path() {
153        let endpoint = RemoteEndpoint::parse("ssh://example.com").unwrap();
154        let location = ResourceLocation::remote(endpoint, PathBuf::from("/etc/hostname"));
155        assert_eq!(location.local_path(), None);
156        assert!(location.label().starts_with("ssh://example.com"));
157    }
158
159    #[test]
160    fn resource_uri_roundtrips_without_namespace_fallback() {
161        let local = ResourceLocation::local("/tmp/a b#c%/ssh:literal".into());
162        assert_eq!(
163            ResourceLocation::parse_uri(&local.uri().unwrap()).unwrap(),
164            local
165        );
166        assert!(ResourceLocation::parse_uri("file://another-host/tmp/a").is_err());
167        assert!(ResourceLocation::parse_uri("file:///tmp/%00").is_err());
168        assert!(ResourceLocation::parse_uri("container:short/tmp/a").is_err());
169        let container = ResourceLocation {
170            filesystem: Filesystem::Container(
171                crate::ContainerId::canonical("a".repeat(64)).unwrap(),
172            ),
173            path: "/tmp/a b".into(),
174        };
175        assert_eq!(
176            ResourceLocation::parse_uri(&container.uri().unwrap()).unwrap(),
177            container
178        );
179    }
180
181    #[cfg(unix)]
182    #[test]
183    fn non_utf8_names_and_literal_escape_text_have_distinct_display_and_uri() {
184        use std::os::unix::ffi::OsStringExt;
185        let path = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/a\xff".to_vec()));
186        let resource = ResourceLocation::local(path);
187        let literal = ResourceLocation::local("/tmp/a\\xFF".into());
188        assert_ne!(resource.label(), literal.label());
189        assert_ne!(resource.uri().unwrap(), literal.uri().unwrap());
190        assert_eq!(
191            ResourceLocation::parse_uri(&resource.uri().unwrap()).unwrap(),
192            resource
193        );
194    }
195}