1use std::path::{Path, PathBuf};
16
17use strop_workspace::{ContainerId, RemoteEndpoint, RemoteFile};
18
19#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum RepoTarget {
24 Local {
26 #[serde(with = "strop_core::path_serde")]
27 workdir: PathBuf,
28 },
29 Remote {
33 endpoint: RemoteEndpoint,
34 #[serde(with = "strop_core::path_serde")]
35 workdir: PathBuf,
36 },
37 Container {
43 container: ContainerId,
44 #[serde(with = "strop_core::path_serde")]
45 workdir: PathBuf,
46 },
47}
48
49impl RepoTarget {
50 pub fn workdir(&self) -> &Path {
55 match self {
56 Self::Local { workdir } | Self::Remote { workdir, .. } => workdir,
57 Self::Container { workdir, .. } => workdir,
58 }
59 }
60
61 pub fn remote_file(&self, rel: &Path) -> Option<RemoteFile> {
66 match self {
67 Self::Local { .. } | Self::Container { .. } => None,
68 Self::Remote { endpoint, workdir } => {
69 RemoteFile::from_path(endpoint.clone(), workdir.join(rel)).ok()
70 }
71 }
72 }
73
74 pub fn is_remote(&self) -> bool {
79 matches!(self, Self::Remote { .. })
80 }
81
82 pub fn is_container(&self) -> bool {
85 matches!(self, Self::Container { .. })
86 }
87
88 pub fn rel_of(&self, path: &Path) -> Option<PathBuf> {
92 path.strip_prefix(self.workdir())
93 .ok()
94 .map(|rel| rel.to_path_buf())
95 }
96
97 pub fn abs_of(&self, rel: &Path) -> PathBuf {
100 self.workdir().join(rel)
101 }
102
103 pub fn endpoint(&self) -> Option<&RemoteEndpoint> {
104 match self {
105 Self::Local { .. } | Self::Container { .. } => None,
106 Self::Remote { endpoint, .. } => Some(endpoint),
107 }
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 fn remote_target() -> RepoTarget {
116 RepoTarget::Remote {
117 endpoint: RemoteEndpoint::parse("ssh://fixture@box.example:2222").unwrap(),
118 workdir: PathBuf::from("/srv/proj"),
119 }
120 }
121
122 #[test]
125 fn remote_identity_is_endpoint_plus_workdir() {
126 let a = remote_target();
127 let same = RepoTarget::Remote {
128 endpoint: RemoteEndpoint::parse("ssh://fixture@box.example:2222").unwrap(),
129 workdir: PathBuf::from("/srv/proj"),
130 };
131 let other_port = RepoTarget::Remote {
132 endpoint: RemoteEndpoint::parse("ssh://fixture@box.example:2223").unwrap(),
133 workdir: PathBuf::from("/srv/proj"),
134 };
135 let other_dir = RepoTarget::Remote {
136 endpoint: RemoteEndpoint::parse("ssh://fixture@box.example:2222").unwrap(),
137 workdir: PathBuf::from("/other"),
138 };
139 assert_eq!(a, same);
140 assert_ne!(a, other_port);
141 assert_ne!(a, other_dir);
142 assert_ne!(
143 a,
144 RepoTarget::Local {
145 workdir: PathBuf::from("/srv/proj")
146 }
147 );
148 }
149
150 #[test]
154 fn rel_of_strips_the_owning_workdir() {
155 let target = remote_target();
156 assert_eq!(
157 target.rel_of(Path::new("/srv/proj/src/main.rs")),
158 Some(PathBuf::from("src/main.rs"))
159 );
160 assert_eq!(target.rel_of(Path::new("/home/me/src/main.rs")), None);
161 }
162
163 #[test]
167 fn remote_file_carries_endpoint_and_native_path() {
168 let target = remote_target();
169 let file = target.remote_file(Path::new("src/a b.rs")).unwrap();
170 assert_eq!(
171 file.endpoint(),
172 &RemoteEndpoint::parse("ssh://fixture@box.example:2222").unwrap()
173 );
174 assert_eq!(file.path(), Path::new("/srv/proj/src/a b.rs"));
175 assert!(target.remote_file(Path::new("x")).is_some());
176 }
177
178 #[test]
181 fn local_target_has_no_remote_identity() {
182 let target = RepoTarget::Local {
183 workdir: PathBuf::from("/w"),
184 };
185 assert_eq!(target.endpoint(), None);
186 assert!(!target.is_remote());
187 assert_eq!(target.remote_file(Path::new("a.rs")), None);
188 }
189
190 #[test]
193 fn serde_round_trips_remote_provenance() {
194 let target = remote_target();
195 let text = serde_json::to_string(&target).unwrap();
196 let back: RepoTarget = serde_json::from_str(&text).unwrap();
197 assert_eq!(target, back);
198 }
199
200 fn container_target() -> RepoTarget {
201 RepoTarget::Container {
202 container: ContainerId::canonical("b".repeat(64)).unwrap(),
203 workdir: PathBuf::from("/work/src"),
204 }
205 }
206
207 #[test]
211 fn container_target_is_neither_remote_nor_local() {
212 let target = container_target();
213 assert!(target.is_container());
214 assert!(!target.is_remote());
215 assert_eq!(target.endpoint(), None);
216 assert_eq!(target.remote_file(Path::new("a.rs")), None);
217 assert_eq!(target.workdir(), Path::new("/work/src"));
218 assert_eq!(
219 target.rel_of(Path::new("/work/src/lib.rs")),
220 Some(PathBuf::from("lib.rs"))
221 );
222 }
223
224 #[test]
227 fn container_identity_is_id_plus_workdir() {
228 let a = container_target();
229 let other_id = RepoTarget::Container {
230 container: ContainerId::canonical("c".repeat(64)).unwrap(),
231 workdir: PathBuf::from("/work/src"),
232 };
233 assert_ne!(a, other_id);
234 assert_ne!(
235 a,
236 RepoTarget::Local {
237 workdir: PathBuf::from("/work/src")
238 }
239 );
240 }
241
242 #[test]
246 fn serde_round_trips_container_and_decodes_legacy_variants() {
247 let target = container_target();
248 let text = serde_json::to_string(&target).unwrap();
249 assert!(text.contains("\"container\":"), "{text}");
250 let back: RepoTarget = serde_json::from_str(&text).unwrap();
251 assert_eq!(target, back);
252
253 let legacy_local: RepoTarget =
255 serde_json::from_str(r#"{"local":{"workdir":"/w"}}"#).unwrap();
256 assert_eq!(
257 legacy_local,
258 RepoTarget::Local {
259 workdir: PathBuf::from("/w")
260 }
261 );
262 let legacy_remote: RepoTarget = serde_json::from_str(
263 r#"{"remote":{"endpoint":"ssh://fixture@box.example:2222","workdir":"/srv/proj"}}"#,
264 )
265 .unwrap();
266 assert_eq!(legacy_remote, remote_target());
267 }
268}