1use std::collections::BTreeSet;
18use std::io::{BufRead as _, Read as _, Write as _};
19use std::sync::atomic::{AtomicU64, Ordering};
20
21use camino::{Utf8Path, Utf8PathBuf};
22use ignore::gitignore::{Gitignore, GitignoreBuilder};
23
24use crate::diagnostic::{Diagnostic, Reason};
25use crate::error::RkError;
26
27use super::Context;
28
29pub const INCLUDE_KEY: &str = "profile.release.build.include";
31
32pub const EXCLUDE_KEY: &str = "profile.release.build.exclude";
34
35pub const VERIFY_KEY: &str = "profile.release.build.verify";
37
38pub const VERSION_FILE: &str = "VERSION";
41
42pub const NOTHING_YET: &str =
45 "no packed file exists yet: profile.release.build.include matches no tracked file";
46
47const RECORD_DIR: &str = ".release-kit/";
49
50const GENERATED: [&str; 2] = ["PKG-INFO", "pyproject.toml.orig"];
52
53#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Comparison {
58 pub tag: String,
60 pub paths: Vec<String>,
62 pub changed: bool,
65 pub history: Vec<String>,
70 pub inputs: Vec<String>,
75}
76
77pub struct Declaration {
81 include: Option<Gitignore>,
82 exclude: Gitignore,
83}
84
85impl Declaration {
86 pub fn parse(key: &str, patterns: &[String]) -> Result<Gitignore, String> {
93 let mut builder = GitignoreBuilder::new("");
94 for pattern in patterns {
95 builder.add_line(None, pattern).map_err(|error| {
96 format!("{key} holds {pattern:?}, which is no gitignore-style pattern: {error}")
97 })?;
98 }
99 builder
100 .build()
101 .map_err(|error| format!("{key} does not build a matcher: {error}"))
102 }
103
104 pub fn new(include: &[String], exclude: &[String]) -> Result<Self, String> {
111 Ok(Self {
112 include: if include.is_empty() {
113 None
114 } else {
115 Some(Self::parse(INCLUDE_KEY, include)?)
116 },
117 exclude: Self::parse(EXCLUDE_KEY, exclude)?,
118 })
119 }
120
121 pub fn at(root: &Utf8Path, rev: &str) -> Result<Self, RkError> {
129 let (include, exclude) = recorded(root, rev);
130 Self::new(&include, &exclude).map_err(|message| {
131 RkError::refusal(
132 Diagnostic::new(Reason::ConfigInvalid, format!("at {rev}: {message}"))
133 .expected("gitignore-style patterns, as Cargo's include and exclude take")
134 .target_state("unchanged"),
135 )
136 })
137 }
138
139 fn names(&self, path: &str) -> bool {
141 self.include
142 .as_ref()
143 .is_some_and(|include| include.matched_path_or_any_parents(path, false).is_ignore())
144 }
145
146 #[must_use]
151 pub fn ships(&self, path: &str) -> bool {
152 if self.include.is_some() {
153 return self.names(path);
154 }
155 !self
156 .exclude
157 .matched_path_or_any_parents(path, false)
158 .is_ignore()
159 }
160}
161
162fn recorded(root: &Utf8Path, rev: &str) -> (Vec<String>, Vec<String>) {
166 let spec = format!("{rev}:{RECORD_DIR}config.toml");
167 let text = super::git(root, &["show", &spec])
168 .ok()
169 .filter(|output| output.status.success())
170 .map(|output| String::from_utf8_lossy(&output.stdout).into_owned())
171 .unwrap_or_default();
172 let table: toml::Table = toml::from_str(&text).unwrap_or_default();
173 let build = table
174 .get("profile")
175 .and_then(|profile| profile.get("release"))
176 .and_then(|release| release.get("build"));
177 let list = |key: &str| -> Vec<String> {
178 build
179 .and_then(|build| build.get(key))
180 .and_then(toml::Value::as_array)
181 .map(|items| {
182 items
183 .iter()
184 .filter_map(|item| item.as_str().map(str::to_owned))
185 .collect()
186 })
187 .unwrap_or_default()
188 };
189 (list("include"), list("exclude"))
190}
191
192#[must_use]
198pub fn declared(
199 tracked: &BTreeSet<String>,
200 declaration: &Declaration,
201 destinations: &BTreeSet<String>,
202) -> BTreeSet<String> {
203 tracked
204 .iter()
205 .filter(|path| {
206 if path.as_str() == VERSION_FILE {
207 return true;
208 }
209 let rk_owned = path.starts_with(RECORD_DIR) || destinations.contains(*path);
210 if rk_owned {
211 declaration.names(path)
212 } else {
213 declaration.ships(path)
214 }
215 })
216 .cloned()
217 .collect()
218}
219
220#[must_use]
223pub fn is_empty_package(files: &BTreeSet<String>) -> bool {
224 files.iter().all(|path| path == VERSION_FILE)
225}
226
227#[must_use]
229pub fn empty_package(rev: &str) -> RkError {
230 RkError::refusal(
231 Diagnostic::new(
232 Reason::PrerequisiteUnmet,
233 format!(
234 "the package at {rev} holds no file past {VERSION_FILE}: {INCLUDE_KEY} and {EXCLUDE_KEY} select no tracked file"
235 ),
236 )
237 .expected("a declaration that selects the tracked files the release ships")
238 .action(format!(
239 "widen {INCLUDE_KEY} or {EXCLUDE_KEY} in .release-kit/config.toml, run rk upgrade --apply, and read rk release build --list"
240 ))
241 .target_state("no artifact, upload, tag, or release"),
242 )
243}
244
245#[must_use]
248pub fn strip_top(members: &[String]) -> Vec<String> {
249 let clean: Vec<&str> = members
250 .iter()
251 .map(|member| member.trim_start_matches("./"))
252 .filter(|member| !member.is_empty())
253 .collect();
254 let top = clean.first().and_then(|member| member.split_once('/'));
255 let shared = top.is_some_and(|(top, _)| {
256 clean.iter().all(|member| {
257 member
258 .split_once('/')
259 .is_some_and(|(first, _)| first == top)
260 })
261 });
262 clean
263 .iter()
264 .map(|member| {
265 if shared {
266 member.split_once('/').map_or(*member, |(_, rest)| rest)
267 } else {
268 member
269 }
270 })
271 .filter(|member| !member.is_empty() && !member.ends_with('/'))
272 .map(str::to_owned)
273 .collect()
274}
275
276#[must_use]
278pub fn drop_generated(members: Vec<String>) -> Vec<String> {
279 members
280 .into_iter()
281 .filter(|member| !GENERATED.contains(&member.as_str()))
282 .collect()
283}
284
285#[must_use]
287pub fn keep_tracked(members: Vec<String>, tracked: &BTreeSet<String>) -> BTreeSet<String> {
288 members
289 .into_iter()
290 .filter(|member| tracked.contains(member))
291 .collect()
292}
293
294#[must_use]
297pub fn subtract(list: BTreeSet<String>, destinations: &BTreeSet<String>) -> BTreeSet<String> {
298 list.into_iter()
299 .filter(|path| !path.starts_with(RECORD_DIR) && !destinations.contains(path))
300 .collect()
301}
302
303#[must_use]
306pub fn cliff_pattern(path: &str) -> String {
307 let mut pattern = String::with_capacity(path.len());
308 for c in path.chars() {
309 if matches!(c, '*' | '?' | '[' | ']') {
310 pattern.push('[');
311 pattern.push(c);
312 pattern.push(']');
313 } else {
314 pattern.push(c);
315 }
316 }
317 pattern
318}
319
320pub fn members(archive: &Utf8Path) -> Result<Vec<String>, RkError> {
326 let unreadable = |source: std::io::Error| {
327 RkError::check_failed(
328 Diagnostic::new(
329 Reason::PostconditionFailed,
330 format!("{archive} is not a gzip tarball rk can read: {source}"),
331 )
332 .expected("a .tar.gz the build wrote")
333 .target_state("unchanged"),
334 )
335 };
336 let file = std::fs::File::open(archive).map_err(RkError::Io)?;
337 let mut tarball = tar::Archive::new(flate2::read::GzDecoder::new(file));
338 let mut found = Vec::new();
339 for entry in tarball.entries().map_err(unreadable)? {
340 let entry = entry.map_err(unreadable)?;
341 let kind = entry.header().entry_type();
342 if !(kind.is_file() || kind.is_symlink() || kind.is_hard_link()) {
343 continue;
344 }
345 let path = entry.path().map_err(unreadable)?;
346 found.push(path.to_string_lossy().into_owned());
347 }
348 Ok(found)
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct Entry {
355 pub mode: String,
357 pub object: String,
359 pub path: String,
361}
362
363pub fn entries(root: &Utf8Path, rev: &str) -> Result<Vec<Entry>, RkError> {
370 let listed = super::git_ok(
371 root,
372 &["ls-tree", "-r", "-z", "--full-tree", rev],
373 "the revision's tree",
374 )?;
375 let mut found: Vec<Entry> = listed
376 .split('\0')
377 .filter_map(|line| {
378 let (meta, path) = line.split_once('\t')?;
379 let mut parts = meta.split(' ');
380 let (mode, kind, object) = (parts.next()?, parts.next()?, parts.next()?);
381 (kind == "blob").then(|| Entry {
382 mode: mode.to_owned(),
383 object: object.to_owned(),
384 path: path.to_owned(),
385 })
386 })
387 .collect();
388 found.sort_by(|a, b| a.path.cmp(&b.path));
389 Ok(found)
390}
391
392pub fn tracked(root: &Utf8Path, rev: &str) -> Result<BTreeSet<String>, RkError> {
398 Ok(entries(root, rev)?
399 .into_iter()
400 .map(|entry| entry.path)
401 .collect())
402}
403
404pub fn bash_set(root: &Utf8Path, rev: &str) -> Result<BTreeSet<String>, RkError> {
411 Ok(declared(
412 &tracked(root, rev)?,
413 &Declaration::at(root, rev)?,
414 &destinations(root, rev),
415 ))
416}
417
418fn blobs(root: &Utf8Path, objects: &[&str]) -> Result<Vec<Vec<u8>>, RkError> {
420 let failed = |message: String| {
421 RkError::subprocess(
422 Diagnostic::new(Reason::SubprocessFailed, message)
423 .expected("git cat-file to read every packed blob"),
424 )
425 };
426 let mut command = std::process::Command::new(crate::probes::git_bin());
427 for var in crate::maintenance::GIT_HOOK_VARS {
428 command.env_remove(var);
429 }
430 let mut child = command
431 .arg("-C")
432 .arg(root.as_std_path())
433 .args(["cat-file", "--batch"])
434 .stdin(std::process::Stdio::piped())
435 .stdout(std::process::Stdio::piped())
436 .stderr(std::process::Stdio::null())
437 .spawn()
438 .map_err(|source| failed(format!("git did not run: {source}")))?;
439 let mut stdin = child
440 .stdin
441 .take()
442 .ok_or_else(|| failed("git cat-file took no input".to_owned()))?;
443 let mut input = String::new();
444 for object in objects {
445 input.push_str(object);
446 input.push('\n');
447 }
448 let writer = std::thread::spawn(move || stdin.write_all(input.as_bytes()));
449 let mut reader = std::io::BufReader::new(
450 child
451 .stdout
452 .take()
453 .ok_or_else(|| failed("git cat-file gave no output".to_owned()))?,
454 );
455 let mut out = Vec::with_capacity(objects.len());
456 for object in objects {
457 let mut header = String::new();
458 reader.read_line(&mut header).map_err(RkError::Io)?;
459 let size = header
460 .split(' ')
461 .nth(2)
462 .and_then(|size| size.trim().parse::<usize>().ok())
463 .ok_or_else(|| {
464 failed(format!(
465 "git cat-file did not read {object}: {}",
466 header.trim()
467 ))
468 })?;
469 let mut bytes = vec![0; size];
470 reader.read_exact(&mut bytes).map_err(RkError::Io)?;
471 let mut newline = [0; 1];
472 reader.read_exact(&mut newline).map_err(RkError::Io)?;
473 out.push(bytes);
474 }
475 writer
476 .join()
477 .map_err(|_| failed("git cat-file's input stopped".to_owned()))?
478 .map_err(RkError::Io)?;
479 child.wait().map_err(RkError::Io)?;
480 Ok(out)
481}
482
483pub fn pack(
495 root: &Utf8Path,
496 rev: &str,
497 files: &BTreeSet<String>,
498 top: &str,
499 epoch: u64,
500 archive: &Utf8Path,
501) -> Result<(), RkError> {
502 let shipped: Vec<Entry> = entries(root, rev)?
503 .into_iter()
504 .filter(|entry| files.contains(&entry.path))
505 .collect();
506 let objects: Vec<&str> = shipped.iter().map(|entry| entry.object.as_str()).collect();
507 let contents = blobs(root, &objects)?;
508 let file = std::fs::File::create(archive).map_err(RkError::Io)?;
509 let gzip = flate2::GzBuilder::new()
510 .mtime(0)
511 .write(file, flate2::Compression::default());
512 let mut tarball = tar::Builder::new(gzip);
513 for (entry, bytes) in shipped.iter().zip(contents) {
514 let path = format!("{top}/{}", entry.path);
515 let mut header = tar::Header::new_gnu();
516 header.set_uid(0);
517 header.set_gid(0);
518 header.set_username("").map_err(RkError::Io)?;
519 header.set_groupname("").map_err(RkError::Io)?;
520 header.set_mtime(epoch);
521 if entry.mode == "120000" {
522 header.set_entry_type(tar::EntryType::Symlink);
523 header.set_mode(0o777);
524 header.set_size(0);
525 let target = String::from_utf8_lossy(&bytes).into_owned();
526 tarball
527 .append_link(&mut header, &path, &target)
528 .map_err(RkError::Io)?;
529 } else {
530 header.set_entry_type(tar::EntryType::Regular);
531 header.set_mode(if entry.mode == "100755" { 0o755 } else { 0o644 });
532 header.set_size(bytes.len() as u64);
533 tarball
534 .append_data(&mut header, &path, bytes.as_slice())
535 .map_err(RkError::Io)?;
536 }
537 }
538 tarball
539 .into_inner()
540 .map_err(RkError::Io)?
541 .finish()
542 .map_err(RkError::Io)?;
543 Ok(())
544}
545
546pub fn unpack(archive: &Utf8Path, dir: &Utf8Path) -> Result<(), RkError> {
552 let file = std::fs::File::open(archive).map_err(RkError::Io)?;
553 tar::Archive::new(flate2::read::GzDecoder::new(file))
554 .unpack(dir)
555 .map_err(RkError::Io)
556}
557
558#[must_use]
561pub fn destinations(root: &Utf8Path, rev: &str) -> BTreeSet<String> {
562 let spec = format!("{rev}:{RECORD_DIR}manifest.json");
563 let Ok(output) = super::git(root, &["show", &spec]) else {
564 return BTreeSet::new();
565 };
566 if !output.status.success() {
567 return BTreeSet::new();
568 }
569 serde_json::from_slice::<serde_json::Value>(&output.stdout)
570 .ok()
571 .and_then(|record| record.get("files").cloned())
572 .and_then(|files| files.as_array().cloned())
573 .unwrap_or_default()
574 .iter()
575 .filter_map(|file| file.get("destination")?.as_str().map(str::to_owned))
576 .collect()
577}
578
579pub fn epoch(root: &Utf8Path, rev: &str) -> Result<String, RkError> {
585 super::git_line(
586 root,
587 &["log", "-1", "--format=%ct", rev],
588 "the revision's commit time",
589 )
590}
591
592#[must_use]
594pub fn mirrors_nothing(what: &str, rev: &str) -> RkError {
595 RkError::refusal(
596 Diagnostic::new(
597 Reason::PrerequisiteUnmet,
598 format!(
599 "{what} at {rev} holds no file git tracks there, so the request cannot tell which files the release packs"
600 ),
601 )
602 .expected("a package whose members, below one top directory, are tracked files")
603 .action("configure the build backend to pack the project's tracked files, then read the sdist with tar -tzf")
604 .target_state("unchanged"),
605 )
606}
607
608pub struct TempDir {
611 dir: Utf8PathBuf,
612}
613
614impl TempDir {
615 pub fn new(purpose: &str) -> Result<Self, RkError> {
621 static COUNT: AtomicU64 = AtomicU64::new(0);
622 let base = Utf8PathBuf::from_path_buf(std::env::temp_dir()).map_err(|path| {
623 RkError::refusal(
624 Diagnostic::new(
625 Reason::PrerequisiteUnmet,
626 format!("the temporary directory {} is not UTF-8", path.display()),
627 )
628 .expected("a UTF-8 temporary directory")
629 .target_state("unchanged"),
630 )
631 })?;
632 let nanos = std::time::SystemTime::now()
633 .duration_since(std::time::UNIX_EPOCH)
634 .map_or(0, |elapsed| elapsed.subsec_nanos());
635 let dir = base.join(format!(
636 "rk-{purpose}-{}-{nanos}-{}",
637 std::process::id(),
638 COUNT.fetch_add(1, Ordering::Relaxed)
639 ));
640 std::fs::create_dir_all(&dir).map_err(RkError::Io)?;
641 Ok(Self { dir })
642 }
643
644 #[must_use]
646 pub fn path(&self) -> &Utf8Path {
647 &self.dir
648 }
649}
650
651impl Drop for TempDir {
652 fn drop(&mut self) {
653 let _ = std::fs::remove_dir_all(&self.dir);
654 }
655}
656
657pub struct Scratch {
660 root: Utf8PathBuf,
661 dir: TempDir,
662}
663
664impl Scratch {
665 pub fn new(root: &Utf8Path, rev: &str) -> Result<Self, RkError> {
672 let scratch = Self {
673 root: root.to_owned(),
674 dir: TempDir::new("packed")?,
675 };
676 let tree = scratch.tree();
677 super::git_ok(
678 root,
679 &[
680 "-c",
681 "core.hooksPath=/dev/null",
682 "worktree",
683 "add",
684 "--detach",
685 "-q",
686 tree.as_str(),
687 rev,
688 ],
689 "a temporary worktree of the revision",
690 )?;
691 Ok(scratch)
692 }
693
694 #[must_use]
696 pub fn tree(&self) -> Utf8PathBuf {
697 self.dir.path().join("tree")
698 }
699
700 #[must_use]
702 pub fn out(&self) -> Utf8PathBuf {
703 self.dir.path().join("out")
704 }
705}
706
707impl Drop for Scratch {
708 fn drop(&mut self) {
709 let tree = self.tree();
710 let _ = super::git(
711 &self.root,
712 &["worktree", "remove", "--force", tree.as_str()],
713 );
714 let _ = std::fs::remove_dir_all(&tree);
715 let _ = super::git(&self.root, &["worktree", "prune"]);
716 }
717}
718
719fn side(context: &Context, rev: &str) -> Result<BTreeSet<String>, RkError> {
721 let listed = context.adapter.packed(&context.target, rev)?;
722 Ok(subtract(listed, &destinations(&context.target, rev)))
723}
724
725pub fn compare(context: &Context, tag: &str) -> Result<Comparison, RkError> {
732 let root = &context.target;
733 let at = format!("refs/tags/{tag}^{{commit}}");
734 let held = super::git(root, &["rev-parse", "--verify", "-q", &at])?;
735 if !held.status.success() {
736 return Err(RkError::refusal(
737 Diagnostic::new(
738 Reason::ObservationIncomplete,
739 format!("the release tag {tag} is not in the checkout"),
740 )
741 .expected("a checkout that holds every release tag, as fetch-depth 0 fetches them")
742 .target_state("unchanged"),
743 ));
744 }
745 let at_head = side(context, "HEAD")?;
746 let released = side(context, tag)?;
747 let moved = at_head != released;
751 let declared = moved && recorded(root, "HEAD") != recorded(root, tag);
752 let mut list = at_head;
753 list.extend(released);
754 if list.is_empty() {
758 return Ok(Comparison {
759 tag: tag.to_owned(),
760 paths: Vec::new(),
761 changed: false,
762 history: Vec::new(),
763 inputs: Vec::new(),
764 });
765 }
766 let paths: Vec<String> = list.into_iter().collect();
767 let changed = moved || differs(root, tag, &paths)?;
768 let mut history = paths.clone();
769 let mut inputs = paths.clone();
770 if let Some((file, _)) = super::adapter::version_file(context.adapter.driver())
776 && !history.iter().any(|path| path == file)
777 {
778 history.push(file.to_owned());
779 }
780 if declared {
781 history.push(format!("{RECORD_DIR}config.toml"));
782 inputs.push(format!("{RECORD_DIR}config.toml"));
783 }
784 Ok(Comparison {
785 tag: tag.to_owned(),
786 paths,
787 changed,
788 history,
789 inputs,
790 })
791}
792
793pub fn differs(root: &Utf8Path, tag: &str, paths: &[String]) -> Result<bool, RkError> {
800 let mut args = vec![
801 "--literal-pathspecs",
802 "diff",
803 "--quiet",
804 "--no-ext-diff",
805 "--no-textconv",
806 tag,
807 "HEAD",
808 "--",
809 ];
810 args.extend(paths.iter().map(String::as_str));
811 let output = super::git(root, &args)?;
812 match output.status.code() {
813 Some(0) => Ok(false),
814 Some(1) => Ok(true),
815 _ => Err(RkError::subprocess(
816 Diagnostic::new(Reason::SubprocessFailed, super::last_line(&output.stderr))
817 .expected("git to compare the packed files"),
818 )),
819 }
820}
821
822pub fn at_head(context: &Context) -> Result<Vec<String>, RkError> {
830 Ok(side(context, "HEAD")?.into_iter().collect())
831}
832
833pub fn stable_tag_below(root: &Utf8Path, exclude: Option<&str>) -> Result<Option<String>, RkError> {
843 let mut args = vec![
844 "describe",
845 "--tags",
846 "--abbrev=0",
847 "--match",
848 "v[0-9]*",
849 "--exclude",
850 "*-*",
851 ];
852 if let Some(tag) = exclude {
853 args.extend(["--exclude", tag]);
854 }
855 args.push("HEAD");
856 let output = super::git(root, &args)?;
857 Ok(output
858 .status
859 .success()
860 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
861 .filter(|found| !found.is_empty()))
862}
863
864pub fn previous_tag(root: &Utf8Path, tag: &str) -> Result<Option<String>, RkError> {
870 let output = super::git(
871 root,
872 &[
873 "describe",
874 "--tags",
875 "--abbrev=0",
876 "--match",
877 "v[0-9]*",
878 "--exclude",
879 tag,
880 "HEAD",
881 ],
882 )?;
883 Ok(output
884 .status
885 .success()
886 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
887 .filter(|found| !found.is_empty()))
888}
889
890#[cfg(test)]
891mod tests {
892 use std::collections::BTreeSet;
893
894 use super::{cliff_pattern, drop_generated, keep_tracked, strip_top, subtract};
895
896 fn owned(paths: &[&str]) -> Vec<String> {
897 paths.iter().map(|path| (*path).to_owned()).collect()
898 }
899
900 fn set(paths: &[&str]) -> BTreeSet<String> {
901 owned(paths).into_iter().collect()
902 }
903
904 #[test]
910 fn the_packed_list_arithmetic_holds() {
911 assert_eq!(
912 strip_top(&owned(&[
913 "acme-0.1.0/",
914 "acme-0.1.0/src/a.py",
915 "acme-0.1.0/PKG-INFO"
916 ])),
917 owned(&["src/a.py", "PKG-INFO"])
918 );
919 assert_eq!(
920 strip_top(&owned(&["./widget/README.md", "./widget/bin/tool"])),
921 owned(&["README.md", "bin/tool"])
922 );
923 assert_eq!(
924 strip_top(&owned(&["README.md", "src/tool.sh"])),
925 owned(&["README.md", "src/tool.sh"]),
926 "members with no shared top directory stay whole"
927 );
928 assert_eq!(strip_top(&owned(&["a/x", "b/y"])), owned(&["a/x", "b/y"]));
929 assert_eq!(
930 drop_generated(owned(&[
931 "PKG-INFO",
932 "pyproject.toml.orig",
933 "pyproject.toml",
934 "src/PKG-INFO"
935 ])),
936 owned(&["pyproject.toml", "src/PKG-INFO"])
937 );
938 let tracked = set(&["pyproject.toml", "src/a.py", "Makefile"]);
939 assert_eq!(
940 keep_tracked(
941 owned(&["pyproject.toml", "src/a.py", "build/out.bin"]),
942 &tracked
943 ),
944 set(&["pyproject.toml", "src/a.py"])
945 );
946 let head = subtract(
947 set(&[
948 ".gitlab-ci.yml",
949 ".release-kit/config.toml",
950 "src/a.py",
951 "cliff.toml",
952 ]),
953 &set(&[".gitlab-ci.yml", "cliff.toml"]),
954 );
955 let at_tag = subtract(
956 set(&[".gitlab-ci.yml", "old.py", "cliff.toml"]),
957 &set(&["cliff.toml"]),
958 );
959 let mut union = head;
960 union.extend(at_tag);
961 assert_eq!(
962 union,
963 set(&[".gitlab-ci.yml", "old.py", "src/a.py"]),
964 "a destination the tag's record does not name counts from the tag's side"
965 );
966 assert_eq!(cliff_pattern("src/a[1]*.py"), "src/a[[]1[]][*].py");
967 assert_eq!(cliff_pattern("src/tool.sh"), "src/tool.sh");
968 }
969
970 fn selected(
972 include: &[&str],
973 exclude: &[&str],
974 tracked: &[&str],
975 recorded: &[&str],
976 ) -> BTreeSet<String> {
977 let declaration =
978 super::Declaration::new(&owned(include), &owned(exclude)).expect("the patterns parse");
979 super::declared(&set(tracked), &declaration, &set(recorded))
980 }
981
982 #[test]
992 fn the_declaration_follows_cargo_semantics() {
993 let tree = [
994 "README.md",
995 "VERSION",
996 "Makefile",
997 "docs/README.md",
998 "docs/guide.md",
999 "src/tool.sh",
1000 "src/lib/util.sh",
1001 "src/lib/test_util.sh",
1002 "tests/check.sh",
1003 "nested/tests/data.txt",
1004 "SECURITY.md",
1005 ".gitlab-ci.yml",
1006 ".release-kit/config.toml",
1007 ".release-kit/manifest.json",
1008 ];
1009 let recorded = [".gitlab-ci.yml", "SECURITY.md", "VERSION"];
1010 let all = |paths: &[&str]| set(paths);
1011 assert_eq!(
1013 selected(&[], &[], &tree, &recorded),
1014 all(&[
1015 "README.md",
1016 "VERSION",
1017 "Makefile",
1018 "docs/README.md",
1019 "docs/guide.md",
1020 "src/tool.sh",
1021 "src/lib/util.sh",
1022 "src/lib/test_util.sh",
1023 "tests/check.sh",
1024 "nested/tests/data.txt",
1025 ])
1026 );
1027 let any_depth = selected(&[], &["README.md"], &tree, &recorded);
1029 assert!(!any_depth.contains("README.md") && !any_depth.contains("docs/README.md"));
1030 let anchored = selected(&[], &["/README.md"], &tree, &recorded);
1031 assert!(!anchored.contains("README.md") && anchored.contains("docs/README.md"));
1032 let dirs = selected(&[], &["tests/"], &tree, &recorded);
1034 assert!(!dirs.contains("tests/check.sh") && !dirs.contains("nested/tests/data.txt"));
1035 let root_dir = selected(&[], &["/tests/"], &tree, &recorded);
1036 assert!(!root_dir.contains("tests/check.sh") && root_dir.contains("nested/tests/data.txt"));
1037 let deep = selected(&["src/**/*.sh"], &[], &tree, &recorded);
1039 assert_eq!(
1040 deep,
1041 all(&[
1042 "VERSION",
1043 "src/tool.sh",
1044 "src/lib/util.sh",
1045 "src/lib/test_util.sh"
1046 ])
1047 );
1048 let shallow = selected(&["/src/*.sh"], &[], &tree, &recorded);
1049 assert_eq!(shallow, all(&["VERSION", "src/tool.sh"]));
1050 assert_eq!(
1052 selected(&["src/", "!test_*.sh"], &[], &tree, &recorded),
1053 all(&["VERSION", "src/tool.sh", "src/lib/util.sh"])
1054 );
1055 assert_eq!(
1057 selected(&["src/"], &["src/"], &tree, &recorded),
1058 all(&[
1059 "VERSION",
1060 "src/tool.sh",
1061 "src/lib/util.sh",
1062 "src/lib/test_util.sh"
1063 ])
1064 );
1065 assert!(!selected(&["*.txt"], &[], &tree, &recorded).contains("notes.txt"));
1067 assert!(selected(&[], &["VERSION"], &tree, &recorded).contains("VERSION"));
1069 assert!(selected(&["src/"], &[], &tree, &recorded).contains("VERSION"));
1070 assert!(!selected(&[], &[], &tree, &recorded).contains("SECURITY.md"));
1072 assert!(!selected(&["*.md"], &[], &tree, &recorded).contains(".release-kit/config.toml"));
1073 let named = selected(&["/SECURITY.md", "src/"], &[], &tree, &recorded);
1074 assert!(named.contains("SECURITY.md") && !named.contains(".gitlab-ci.yml"));
1075 assert!(
1076 selected(&[".release-kit/"], &[], &tree, &recorded)
1077 .contains(".release-kit/config.toml")
1078 );
1079 assert_eq!(
1081 selected(&["/lib/"], &[], &tree, &recorded),
1082 all(&["VERSION"])
1083 );
1084 }
1085}