1use crate::Error;
3use crate::Version;
4use crate::workspace::{Editor, Workspace};
5use debian_changelog::ChangeLog;
6use debian_control::lossless::Control;
7use debian_copyright::lossless::Copyright;
8use debian_watch::parse::ParsedWatchFile;
9use makefile_lossless::Makefile;
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13use toml_edit::DocumentMut;
14
15pub struct FsWorkspace {
25 base_path: PathBuf,
26 package: Option<String>,
27 version: Option<Version>,
28}
29
30impl FsWorkspace {
31 pub fn new(
39 base_path: impl Into<PathBuf>,
40 package: Option<String>,
41 version: Option<Version>,
42 ) -> Self {
43 Self {
44 base_path: base_path.into(),
45 package,
46 version,
47 }
48 }
49
50 pub fn base_path(&self) -> &Path {
52 &self.base_path
53 }
54
55 fn full_path(&self, rel: &Path) -> PathBuf {
56 self.base_path.join(rel)
57 }
58}
59
60struct FsEditor<T> {
66 parsed: T,
67 original: String,
68 path: PathBuf,
69 committed: bool,
70}
71
72impl<T> std::ops::Deref for FsEditor<T> {
73 type Target = T;
74 fn deref(&self) -> &T {
75 &self.parsed
76 }
77}
78
79impl<T> std::ops::DerefMut for FsEditor<T> {
80 fn deref_mut(&mut self) -> &mut T {
81 &mut self.parsed
82 }
83}
84
85impl<T: std::fmt::Display> FsEditor<T> {
86 fn flush(&mut self) -> Result<(), Error> {
87 if self.committed {
88 return Ok(());
89 }
90 let new_text = self.parsed.to_string();
91 if new_text != self.original {
92 fs::write(&self.path, &new_text)?;
93 }
94 self.committed = true;
95 Ok(())
96 }
97}
98
99impl<T: std::fmt::Display + 'static> Editor<T> for FsEditor<T> {
100 fn commit(mut self: Box<Self>) -> Result<(), Error> {
101 self.flush()
102 }
103}
104
105impl<T> Drop for FsEditor<T> {
106 fn drop(&mut self) {
107 if !self.committed {
112 tracing::warn!(
113 "Workspace Editor for {} dropped without commit; \
114 changes (if any) discarded",
115 self.path.display()
116 );
117 }
118 }
119}
120
121impl Workspace for FsWorkspace {
122 fn package(&self) -> Option<&str> {
123 self.package.as_deref()
124 }
125
126 fn current_version(&self) -> Option<&Version> {
127 self.version.as_ref()
128 }
129
130 fn parsed_control(&self) -> Result<Control, Error> {
131 let path = self.full_path(Path::new("debian/control"));
132 let text = fs::read_to_string(&path)?;
133 let (control, errors) = Control::read_relaxed(text.as_bytes())
134 .map_err(|e| Error::Parse(format!("Failed to parse {}: {}", path.display(), e)))?;
135 if !errors.is_empty() {
136 tracing::debug!(
137 "{} has {} parse warning(s): {}",
138 path.display(),
139 errors.len(),
140 errors.join("; ")
141 );
142 }
143 Ok(control)
144 }
145
146 fn parsed_changelog(&self) -> Result<ChangeLog, Error> {
147 let path = self.full_path(Path::new("debian/changelog"));
148 let text = fs::read_to_string(&path)?;
149 ChangeLog::read_relaxed(text.as_bytes())
150 .map_err(|e| Error::Parse(format!("Failed to parse {}: {}", path.display(), e)))
151 }
152
153 fn parsed_copyright(&self) -> Result<Copyright, Error> {
154 let path = self.full_path(Path::new("debian/copyright"));
155 let text = fs::read_to_string(&path)?;
156 let (copyright, errors) = Copyright::from_str_relaxed(&text)
157 .map_err(|e| Error::Parse(format!("Failed to parse {}: {:?}", path.display(), e)))?;
158 if !errors.is_empty() {
159 tracing::debug!(
160 "{} has {} parse warning(s): {}",
161 path.display(),
162 errors.len(),
163 errors.join("; ")
164 );
165 }
166 Ok(copyright)
167 }
168
169 fn parsed_upstream_metadata(&self) -> Result<yaml_edit::YamlFile, Error> {
170 use std::str::FromStr;
171 let path = self.full_path(Path::new("debian/upstream/metadata"));
172 let text = fs::read_to_string(&path)?;
173 yaml_edit::YamlFile::from_str(&text)
174 .map_err(|e| Error::Parse(format!("Failed to parse {}: {}", path.display(), e)))
175 }
176
177 fn parsed_watch(&self) -> Result<ParsedWatchFile, Error> {
178 let path = self.full_path(Path::new("debian/watch"));
179 let text = fs::read_to_string(&path)?;
180 debian_watch::parse::parse(&text)
181 .map_err(|e| Error::Parse(format!("Failed to parse {}: {:?}", path.display(), e)))
182 }
183
184 fn parsed_rules(&self) -> Result<Makefile, Error> {
185 let path = self.full_path(Path::new("debian/rules"));
186 let bytes = fs::read(&path)?;
187 Makefile::read_relaxed(bytes.as_slice())
188 .map_err(|e| Error::Parse(format!("Failed to parse {}: {}", path.display(), e)))
189 }
190
191 fn source_format(&self) -> Result<Option<String>, Error> {
192 match self.read_file(Path::new("debian/source/format"))? {
193 Some(b) => Ok(std::str::from_utf8(&b)
194 .ok()
195 .map(|s| s.trim().to_string())
196 .filter(|s| !s.is_empty())),
197 None => Ok(None),
198 }
199 }
200
201 fn control(&self) -> Result<Box<dyn Editor<Control> + '_>, Error> {
202 let path = self.full_path(Path::new("debian/control"));
203 let original = fs::read_to_string(&path)?;
204 let parsed: Control = original.parse().map_err(|e: deb822_lossless::ParseError| {
205 Error::Parse(format!("Failed to parse {}: {}", path.display(), e))
206 })?;
207 Ok(Box::new(FsEditor {
208 parsed,
209 original,
210 path,
211 committed: false,
212 }))
213 }
214
215 fn changelog(&self) -> Result<Box<dyn Editor<ChangeLog> + '_>, Error> {
216 let path = self.full_path(Path::new("debian/changelog"));
217 let original = fs::read_to_string(&path)?;
218 let parsed = ChangeLog::read_relaxed(original.as_bytes())
219 .map_err(|e| Error::Parse(format!("Failed to parse {}: {}", path.display(), e)))?;
220 Ok(Box::new(FsEditor {
221 parsed,
222 original,
223 path,
224 committed: false,
225 }))
226 }
227
228 fn debcargo(&self) -> Result<Option<Box<dyn Editor<DocumentMut> + '_>>, Error> {
229 let path = self.full_path(Path::new("debian/debcargo.toml"));
230 let original = match fs::read_to_string(&path) {
231 Ok(s) => s,
232 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
233 Err(e) => return Err(Error::Io(e)),
234 };
235 let parsed: DocumentMut = original
236 .parse()
237 .map_err(|e| Error::Parse(format!("Failed to parse {}: {}", path.display(), e)))?;
238 Ok(Some(Box::new(FsEditor {
239 parsed,
240 original,
241 path,
242 committed: false,
243 })))
244 }
245
246 fn read_file(&self, rel: &Path) -> Result<Option<std::borrow::Cow<'_, [u8]>>, Error> {
247 let path = self.full_path(rel);
248 match fs::read(&path) {
249 Ok(bytes) => Ok(Some(std::borrow::Cow::Owned(bytes))),
250 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
251 Err(e) => Err(Error::Io(e)),
252 }
253 }
254
255 fn write_file(&self, rel: &Path, content: &[u8]) -> Result<(), Error> {
256 let path = self.full_path(rel);
257 fs::write(&path, content)?;
258 Ok(())
259 }
260
261 fn list_dir(&self, rel: &Path) -> Result<Option<Vec<String>>, Error> {
262 let path = self.full_path(rel);
263 let read_dir = match fs::read_dir(&path) {
264 Ok(it) => it,
265 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
266 Err(e) => return Err(Error::Io(e)),
267 };
268 let mut names = Vec::new();
269 for entry in read_dir {
270 let entry = entry?;
271 names.push(entry.file_name().to_string_lossy().into_owned());
272 }
273 Ok(Some(names))
274 }
275
276 fn walk_dir(&self, rel: &Path) -> Result<Option<Vec<PathBuf>>, Error> {
277 let abs = self.full_path(rel);
278 if !abs.exists() {
279 return Ok(None);
280 }
281 let mut out = Vec::new();
282 let mut stack: Vec<PathBuf> = vec![abs.clone()];
283 while let Some(dir) = stack.pop() {
284 let read_dir = match fs::read_dir(&dir) {
285 Ok(it) => it,
286 Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
287 Err(e) => return Err(Error::Io(e)),
288 };
289 for entry in read_dir {
290 let entry = entry?;
291 let ft = entry.file_type()?;
292 let path = entry.path();
293 if ft.is_dir() {
294 stack.push(path);
295 } else if ft.is_file() {
296 let rel_path = path
297 .strip_prefix(&self.base_path)
298 .map(|p| p.to_path_buf())
299 .unwrap_or(path);
300 out.push(rel_path);
301 }
302 }
304 }
305 Ok(Some(out))
306 }
307
308 fn file_mode(&self, rel: &Path) -> Result<Option<u32>, Error> {
309 use std::os::unix::fs::PermissionsExt;
310 let path = self.full_path(rel);
311 match fs::metadata(&path) {
312 Ok(m) => Ok(Some(m.permissions().mode())),
313 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
314 Err(e) => Err(Error::Io(e)),
315 }
316 }
317
318 fn base_path(&self) -> Option<&Path> {
319 Some(&self.base_path)
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use std::str::FromStr;
327 use tempfile::TempDir;
328
329 fn make_pkg(dir: &Path) {
330 let debian = dir.join("debian");
331 fs::create_dir_all(&debian).unwrap();
332 fs::write(
333 debian.join("control"),
334 "Source: foo\n\nPackage: foo\nDescription: bar\n bar\n",
335 )
336 .unwrap();
337 fs::write(
338 debian.join("changelog"),
339 "foo (1.0) unstable; urgency=medium\n\n * Initial.\n\n -- A B <a@b> Mon, 01 Jan 2024 00:00:00 +0000\n",
340 )
341 .unwrap();
342 }
343
344 #[test]
345 fn tree_workspace_reads_and_writes_control() {
346 let tmp = TempDir::new().unwrap();
347 make_pkg(tmp.path());
348
349 let ws = FsWorkspace::new(
350 tmp.path(),
351 Some("foo".into()),
352 Some(Version::from_str("1.0").unwrap()),
353 );
354
355 {
356 let control = ws.control().unwrap();
357 let mut source = control.source().unwrap();
358 source.set_homepage(&url::Url::parse("https://example.com/").unwrap());
359 control.commit().unwrap();
360 }
361
362 let on_disk = fs::read_to_string(tmp.path().join("debian/control")).unwrap();
363 assert!(on_disk.contains("Homepage: https://example.com/"));
364 }
365
366 #[test]
367 fn tree_workspace_read_write_raw_file() {
368 let tmp = TempDir::new().unwrap();
369 make_pkg(tmp.path());
370
371 let ws = FsWorkspace::new(
372 tmp.path(),
373 Some("foo".into()),
374 Some(Version::from_str("1.0").unwrap()),
375 );
376
377 let p = Path::new("debian/control");
378 let bytes = ws.read_file(p).unwrap().unwrap();
379 assert!(bytes.starts_with(b"Source: foo"));
380
381 ws.write_file(Path::new("debian/x"), b"hello").unwrap();
382 let back = ws.read_file(Path::new("debian/x")).unwrap().unwrap();
383 assert_eq!(&*back, b"hello");
384
385 assert!(ws.read_file(Path::new("debian/missing")).unwrap().is_none());
386 }
387
388 #[test]
389 fn tree_workspace_missing_control_is_not_found() {
390 let tmp = TempDir::new().unwrap();
391 let ws = FsWorkspace::new(
393 tmp.path(),
394 Some("foo".into()),
395 Some(Version::from_str("1.0").unwrap()),
396 );
397 assert!(matches!(ws.control(), Err(Error::NotFound)));
398 }
399
400 #[test]
401 fn tree_workspace_walk_dir_returns_relative_files() {
402 let tmp = TempDir::new().unwrap();
403 make_pkg(tmp.path());
404 let nested = tmp.path().join("debian/source");
406 fs::create_dir_all(&nested).unwrap();
407 fs::write(nested.join("format"), "3.0 (quilt)\n").unwrap();
408
409 let ws = FsWorkspace::new(
410 tmp.path(),
411 Some("foo".into()),
412 Some(Version::from_str("1.0").unwrap()),
413 );
414 let mut paths = ws.walk_dir(Path::new("debian")).unwrap().unwrap();
415 paths.sort();
416
417 assert_eq!(
418 paths,
419 vec![
420 PathBuf::from("debian/changelog"),
421 PathBuf::from("debian/control"),
422 PathBuf::from("debian/source/format"),
423 ]
424 );
425 }
426
427 #[test]
428 fn tree_workspace_walk_dir_missing_returns_none() {
429 let tmp = TempDir::new().unwrap();
430 let ws = FsWorkspace::new(
431 tmp.path(),
432 Some("foo".into()),
433 Some(Version::from_str("1.0").unwrap()),
434 );
435 assert!(ws.walk_dir(Path::new("debian")).unwrap().is_none());
436 }
437
438 #[test]
439 fn debcargo_absent_returns_none() {
440 let tmp = TempDir::new().unwrap();
441 make_pkg(tmp.path());
442 let ws = FsWorkspace::new(
443 tmp.path(),
444 Some("foo".into()),
445 Some(Version::from_str("1.0").unwrap()),
446 );
447 assert!(ws.parsed_debcargo().unwrap().is_none());
448 assert!(ws.debcargo().unwrap().is_none());
449 }
450
451 #[test]
452 fn debcargo_read_and_write() {
453 let tmp = TempDir::new().unwrap();
454 make_pkg(tmp.path());
455 let toml = "[source]\nvcs_git = \"https://salsa.debian.org/rust-team/debcargo-conf\"\n";
456 fs::write(tmp.path().join("debian/debcargo.toml"), toml).unwrap();
457
458 let ws = FsWorkspace::new(
459 tmp.path(),
460 Some("foo".into()),
461 Some(Version::from_str("1.0").unwrap()),
462 );
463
464 let doc = ws.parsed_debcargo().unwrap().unwrap();
465 assert_eq!(
466 doc["source"]["vcs_git"].as_str().unwrap(),
467 "https://salsa.debian.org/rust-team/debcargo-conf"
468 );
469
470 {
471 let mut editor = ws.debcargo().unwrap().unwrap();
472 editor["source"]["vcs_git"] =
473 toml_edit::value("https://salsa.debian.org/rust-team/debcargo-conf.git");
474 editor.commit().unwrap();
475 }
476
477 let on_disk = fs::read_to_string(tmp.path().join("debian/debcargo.toml")).unwrap();
478 assert_eq!(
479 on_disk,
480 "[source]\nvcs_git = \"https://salsa.debian.org/rust-team/debcargo-conf.git\"\n"
481 );
482 }
483
484 fn workspace(dir: &Path) -> FsWorkspace {
485 FsWorkspace::new(
486 dir,
487 Some("foo".into()),
488 Some(Version::from_str("1.0").unwrap()),
489 )
490 }
491
492 #[test]
493 fn patches_series_absent_returns_none() {
494 let tmp = TempDir::new().unwrap();
495 make_pkg(tmp.path());
496 assert!(
497 workspace(tmp.path())
498 .parsed_patches_series()
499 .unwrap()
500 .is_none()
501 );
502 }
503
504 #[test]
505 fn patches_series_parsed() {
506 let tmp = TempDir::new().unwrap();
507 make_pkg(tmp.path());
508 let patches = tmp.path().join("debian/patches");
509 fs::create_dir_all(&patches).unwrap();
510 fs::write(patches.join("series"), "one.patch\ntwo.patch\n").unwrap();
511
512 let series = workspace(tmp.path())
513 .parsed_patches_series()
514 .unwrap()
515 .unwrap();
516 assert_eq!(series.entries.len(), 2);
517 }
518
519 #[test]
520 fn patch_absent_returns_none() {
521 let tmp = TempDir::new().unwrap();
522 make_pkg(tmp.path());
523 assert!(
524 workspace(tmp.path())
525 .parsed_patch(Path::new("debian/patches/missing.patch"))
526 .unwrap()
527 .is_none()
528 );
529 }
530
531 #[test]
532 fn patch_header_and_diff_parsed() {
533 let tmp = TempDir::new().unwrap();
534 make_pkg(tmp.path());
535 let patches = tmp.path().join("debian/patches");
536 fs::create_dir_all(&patches).unwrap();
537 fs::write(
538 patches.join("fix.patch"),
539 "Description: Fix a typo\nLast-Update: 2024-01-02\n\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-teh\n+the\n",
540 )
541 .unwrap();
542
543 let (header, patch) = workspace(tmp.path())
544 .parsed_patch(Path::new("debian/patches/fix.patch"))
545 .unwrap()
546 .unwrap();
547 let header = header.expect("patch has a DEP-3 header");
548 assert_eq!(
549 header.as_deb822().get("Description").as_deref(),
550 Some("Fix a typo")
551 );
552 assert_eq!(patch.patch_files().count(), 1);
553 }
554
555 #[test]
556 fn patch_without_header_returns_none_header() {
557 let tmp = TempDir::new().unwrap();
558 make_pkg(tmp.path());
559 let patches = tmp.path().join("debian/patches");
560 fs::create_dir_all(&patches).unwrap();
561 fs::write(
562 patches.join("bare.patch"),
563 "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-teh\n+the\n",
564 )
565 .unwrap();
566
567 let (header, patch) = workspace(tmp.path())
568 .parsed_patch(Path::new("debian/patches/bare.patch"))
569 .unwrap()
570 .unwrap();
571 assert!(header.is_none());
572 assert_eq!(patch.patch_files().count(), 1);
573 }
574}