1pub mod evidence;
30
31use std::collections::BTreeMap;
32
33use serde::{Deserialize, Serialize};
34
35use crate::embedded;
36use crate::error::RkError;
37use crate::landing::{Params, Workflow};
38
39#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ProjectionInput {
43 pub params: Params,
45 pub evidence: TargetEvidence,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct TargetEvidence {
53 pub documents: BTreeMap<String, Vec<u8>>,
57 pub crate_shape: CrateShape,
59 pub flake_nix_present: bool,
61 pub flake_lock_present: bool,
63 pub flake_recorded: bool,
66}
67
68impl TargetEvidence {
69 #[must_use]
71 pub fn document(&self, destination: &str) -> Option<&[u8]> {
72 self.documents.get(destination).map(Vec::as_slice)
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Default)]
79pub struct CrateShape {
80 pub cargo_toml: Option<String>,
82 pub cargo_lock: bool,
84 pub main_rs: bool,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Projection {
95 pub candidates: Vec<Candidate>,
97 pub omissions: Vec<Omission>,
101 pub collisions: Vec<Collision>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Candidate {
109 pub destination: String,
111 pub kind: Kind,
113 pub placement: Placement,
115 pub bytes: Vec<u8>,
119 pub region: Option<Vec<u8>>,
122 pub sources: Vec<String>,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum Placement {
130 Whole,
132 Region {
135 begin: &'static str,
137 end: &'static str,
139 },
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Omission {
145 pub destination: String,
147 pub reason: String,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct Collision {
154 pub destination: String,
156 pub reason: String,
158}
159
160impl Projection {
161 pub fn compute(input: &ProjectionInput) -> Result<Self, RkError> {
171 Self::compute_over(&embedded_snippets(), input)
172 }
173
174 fn compute_over(files: &[(String, &[u8])], input: &ProjectionInput) -> Result<Self, RkError> {
178 let params = &input.params;
179 let evidence = &input.evidence;
180 let mut candidates = Vec::new();
181 for selected in select_pair(files, params.tech(), params.forge())? {
182 if !params.nix() && NIX_DESTINATIONS.contains(&selected.destination.as_str()) {
183 continue;
184 }
185 let kind = kind_of(&selected.destination).ok_or_else(|| {
186 anyhow::anyhow!(
187 "the embedded sources do not classify {}; the kind table is stale",
188 selected.destination
189 )
190 })?;
191 let bytes = match kind {
192 Kind::Rendered => render(selected.bytes, params),
193 Kind::Seeded | Kind::State => selected.bytes.to_vec(),
194 };
195 candidates.push(Candidate {
196 destination: selected.destination,
197 kind,
198 placement: Placement::Whole,
199 bytes,
200 region: None,
201 sources: vec![selected.source.to_owned()],
202 });
203 }
204 let mut collisions = Vec::new();
205 for destination in BLOCK_DESTINATIONS {
206 let (template, sources) = block_template(destination, params.workflow())?;
207 if let Some(whole) = candidates
208 .iter()
209 .find(|candidate| candidate.destination == destination)
210 {
211 return Err(anyhow::anyhow!(
212 "{destination} is both a whole file from {} and a marked region from {}; the embedded sources are defective",
213 whole.sources.join(", "),
214 sources.join(", ")
215 )
216 .into());
217 }
218 let region = render(template.as_bytes(), params);
219 let (begin, end) = block_markers(destination).ok_or_else(|| {
220 anyhow::anyhow!("{destination} is a block destination with no markers")
221 })?;
222 match propose_document(destination, evidence.document(destination), ®ion) {
223 Ok(bytes) => candidates.push(Candidate {
224 destination: destination.to_owned(),
225 kind: Kind::Rendered,
226 placement: Placement::Region { begin, end },
227 bytes,
228 region: Some(region),
229 sources,
230 }),
231 Err(reason) => collisions.push(Collision {
232 destination: destination.to_owned(),
233 reason,
234 }),
235 }
236 }
237 let mut omissions = Vec::new();
238 if let Some((set, reason)) = nix_withholding(params.nix(), evidence) {
239 candidates.retain(|candidate| {
240 if set.contains(&candidate.destination.as_str()) {
241 omissions.push(Omission {
242 destination: candidate.destination.clone(),
243 reason: reason.clone(),
244 });
245 false
246 } else {
247 true
248 }
249 });
250 }
251 candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
252 omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
253 Ok(Self {
254 candidates,
255 omissions,
256 collisions,
257 })
258 }
259}
260
261fn embedded_snippets() -> Vec<(String, &'static [u8])> {
264 embedded::walk(&embedded::SNIPPETS)
265 .into_iter()
266 .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
267 .collect()
268}
269
270pub fn check_pair(tech: &str, forge: &str) -> Result<(), RkError> {
278 select_pair(&embedded_snippets(), tech, forge).map(|_| ())
279}
280
281#[must_use]
284pub fn supported_pairs() -> Vec<(String, String)> {
285 let mut pairs = Vec::new();
286 for (path, _) in embedded_snippets() {
287 let Some(rest) = path.strip_prefix("snippets/") else {
288 continue;
289 };
290 let mut segments = rest.split('/');
291 let (Some(tech), Some(forge), Some(_)) =
292 (segments.next(), segments.next(), segments.next())
293 else {
294 continue;
295 };
296 if tech.starts_with('_') {
297 continue;
298 }
299 let pair = (tech.to_owned(), forge.to_owned());
300 if !pairs.contains(&pair) {
301 pairs.push(pair);
302 }
303 }
304 pairs
305}
306
307#[derive(Debug)]
310pub struct Selected<'a, T> {
311 pub destination: String,
313 pub source: &'a str,
315 pub bytes: &'a T,
317}
318
319pub fn select_pair<'a, T>(
333 files: &'a [(String, T)],
334 tech: &str,
335 forge: &str,
336) -> Result<Vec<Selected<'a, T>>, RkError> {
337 let mut techs: Vec<&str> = Vec::new();
338 for (path, _) in files {
339 if let Some(rest) = path.strip_prefix("snippets/")
340 && let Some((dir, _)) = rest.split_once('/')
341 && !dir.starts_with('_')
342 && !techs.contains(&dir)
343 {
344 techs.push(dir);
345 }
346 }
347 if tech.starts_with('_') || !techs.contains(&tech) {
348 return Err(RkError::Usage(format!(
349 "unknown tech '{tech}'; the bindings are: {}",
350 techs.join(", ")
351 )));
352 }
353 let pair = format!("snippets/{tech}/{forge}/");
354 if !files.iter().any(|(path, _)| path.starts_with(&pair)) {
355 let mut known: Vec<String> = Vec::new();
356 for tech in &techs {
357 let prefix = format!("snippets/{tech}/");
358 for (path, _) in files {
359 if let Some(rest) = path.strip_prefix(&prefix)
360 && let Some((forge, _)) = rest.split_once('/')
361 {
362 let entry = format!("{tech}, {forge}");
363 if !known.contains(&entry) {
364 known.push(entry);
365 }
366 }
367 }
368 }
369 return Err(RkError::Usage(format!(
370 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
371 known.join("; ")
372 )));
373 }
374 let shared = format!("snippets/_shared/{forge}/");
375 let mut out: Vec<Selected<'a, T>> = Vec::new();
376 for zone in [&shared, &pair] {
377 for (path, bytes) in files {
378 let Some(rel) = path.strip_prefix(zone.as_str()) else {
379 continue;
380 };
381 if let Some(existing) = out.iter().find(|selected| selected.destination == rel) {
382 return Err(anyhow::anyhow!(
383 "the shared zone and the pair ({tech}, {forge}) both ship {rel}: {} and {path}; the embedded sources are defective",
384 existing.source
385 )
386 .into());
387 }
388 out.push(Selected {
389 destination: rel.to_owned(),
390 source: path,
391 bytes,
392 });
393 }
394 }
395 Ok(out)
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(rename_all = "lowercase")]
401pub enum Kind {
402 Rendered,
405 Seeded,
408 State,
411}
412
413impl Kind {
414 #[must_use]
416 pub const fn as_str(self) -> &'static str {
417 match self {
418 Self::Rendered => "rendered",
419 Self::Seeded => "seeded",
420 Self::State => "state",
421 }
422 }
423}
424
425const KINDS: [(&str, Kind); 16] = [
431 (".github/workflows/release-plz.yml", Kind::Rendered),
432 (".github/workflows/release-please.yml", Kind::Rendered),
433 (".github/workflows/release.yml", Kind::Rendered),
434 (".github/workflows/pr-title.yml", Kind::Rendered),
435 (".gitlab-ci.yml", Kind::Rendered),
436 ("SECURITY.md", Kind::Rendered),
437 (".gitlab/ci/mr-title.yml", Kind::Rendered),
438 ("release-plz.toml", Kind::Seeded),
439 ("dist-workspace.toml", Kind::Seeded),
440 ("release-please-config.json", Kind::Seeded),
441 ("cliff.toml", Kind::Seeded),
442 ("nix/package.nix", Kind::Seeded),
443 ("flake.nix", Kind::Seeded),
444 (".release-please-manifest.json", Kind::State),
445 ("VERSION", Kind::State),
446 ("flake.lock", Kind::State),
447];
448
449pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
463
464pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
470
471#[must_use]
474pub fn kind_of(destination: &str) -> Option<Kind> {
475 if BLOCK_DESTINATIONS.contains(&destination) {
476 return Some(Kind::Rendered);
477 }
478 KINDS
479 .iter()
480 .find(|(name, _)| *name == destination)
481 .map(|(_, kind)| *kind)
482}
483
484pub fn destinations() -> impl Iterator<Item = &'static str> {
489 KINDS
490 .iter()
491 .map(|(name, _)| *name)
492 .chain(BLOCK_DESTINATIONS)
493}
494
495pub const OWNER_TOKEN: &[u8] = b"OWNER";
502
503pub const REPO_PLACEHOLDER: &str = "OWNER";
508
509pub const REPO_TOKEN: &[u8] = b"RK_REPO";
511
512pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
514
515pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
518
519pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
523
524pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
527
528pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
534
535pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
546 (
547 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
548 b"<!--RK_SECURITY_CONTACT_END-->",
549 ),
550 (
551 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
552 b"<!--RK_SECURITY_RESPONSE_END-->",
553 ),
554 (
555 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
556 b"<!--RK_SECURITY_DEADLINE_END-->",
557 ),
558];
559
560fn acknowledgment(response: &str) -> String {
563 format!("Maintainers acknowledge a report within {response}.")
564}
565
566const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
570
571fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
574 let contact = (!params.security_contact().is_empty())
575 .then(|| params.security_contact().as_bytes().to_vec());
576 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
577 [
578 contact,
579 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
580 promised.then(|| DISCLOSURE_ONLY.to_vec()),
581 ]
582}
583
584fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
590 let ordered = find(baseline, begin)
591 .zip(find(baseline, end))
592 .filter(|(start, stop)| stop > start);
593 let Some((start, stop)) = ordered else {
594 return baseline.to_vec();
595 };
596 let mut out = Vec::with_capacity(baseline.len());
597 out.extend_from_slice(&baseline[..start]);
598 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
599 out.extend_from_slice(&baseline[stop + end.len()..]);
600 out
601}
602
603#[must_use]
621pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
622 let repo = params.repo();
623 let owner = repo.split('/').next().unwrap_or(repo);
624 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
625 if let Some(style) = params.style() {
626 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
627 }
628 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
629 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
630 let escaped = params.line_prefix().replace('/', "\\/");
631 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
632 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
633 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
634 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
635 out = replace_span(&out, begin, end, value.as_deref());
636 }
637 out
638}
639
640#[must_use]
642pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
643 let mut out = Vec::with_capacity(baseline.len());
644 let mut rest = baseline;
645 while let Some(at) = find(rest, token) {
646 out.extend_from_slice(&rest[..at]);
647 out.extend_from_slice(value);
648 rest = &rest[at + token.len()..];
649 }
650 out.extend_from_slice(rest);
651 out
652}
653
654fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
656 haystack
657 .windows(needle.len())
658 .position(|window| window == needle)
659}
660
661pub const AGENTS_DESTINATION: &str = "AGENTS.md";
663
664pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
666
667pub const BLOCK_END: &str = "<!-- END release-kit -->";
669
670pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
675
676pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
678
679pub const BLOCK_DESTINATIONS: [&str; 3] =
685 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
686
687pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
689
690pub const HOOKS_END: &str = "# END release-kit";
692
693pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
697
698pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
700
701pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
703
704pub const AGENTS_LINE_WORKTREE: &str = "blocks/agents-line-worktree.md.in";
706
707pub const AGENTS_LINE_BRANCHES: &str = "blocks/agents-line-branches.md.in";
709
710pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
712
713pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
715
716#[must_use]
718pub const fn routing_line(workflow: Workflow) -> &'static str {
719 match workflow {
720 Workflow::Worktree => AGENTS_LINE_WORKTREE,
721 Workflow::Branches => AGENTS_LINE_BRANCHES,
722 }
723}
724
725pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
732 let name = path.strip_prefix("blocks/").unwrap_or(path);
733 let file = embedded::BLOCKS
734 .get_file(name)
735 .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
736 std::str::from_utf8(file.contents())
737 .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
738}
739
740#[must_use]
744pub fn authored(text: &str) -> &str {
745 text.strip_suffix('\n').unwrap_or(text)
746}
747
748pub const BRANCH_GRAMMAR: &str = r"^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)/[A-Za-z0-9._/-]+|([0-9]+|[A-Z][A-Z0-9]+-[0-9]+)-[A-Za-z0-9._-]+|release[-/].+)$";
756
757pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
767
768#[must_use]
775pub fn scope_is_shaped(scope: &str) -> bool {
776 !scope.is_empty()
777 && scope.chars().all(|c| {
778 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
779 })
780}
781
782#[must_use]
789pub fn compose_routing(template: &str, line: &str) -> String {
790 authored(template).replacen("RK_WORKFLOW_LINE", authored(line), 1)
791}
792
793#[must_use]
797pub fn compose_glossary(template: &str) -> String {
798 authored(template).to_owned()
799}
800
801#[must_use]
811pub fn compose_hooks(template: &str, guard: Option<&str>) -> String {
812 let (guard, skip) = guard.map_or_else(
813 || (String::new(), "no-commit-to-branch"),
814 |entry| {
815 (
816 format!("{}\n", authored(entry)),
817 "no-commit-to-branch,rk-worktree-location",
818 )
819 },
820 );
821 authored(template)
822 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
823 .replacen("RK_SWEEP_SKIP", skip, 1)
824 .replacen("RK_WORKTREE_GUARD", &guard, 1)
825}
826
827pub fn routing_block(workflow: Workflow) -> Result<String, RkError> {
834 Ok(compose_routing(
835 embedded_block(AGENTS_BLOCK)?,
836 embedded_block(routing_line(workflow))?,
837 ))
838}
839
840pub fn glossary_block() -> Result<String, RkError> {
846 Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
847}
848
849pub fn hooks_block(workflow: Workflow) -> Result<String, RkError> {
856 let guard = match workflow {
857 Workflow::Worktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
858 Workflow::Branches => None,
859 };
860 Ok(compose_hooks(embedded_block(PRE_COMMIT_BLOCK)?, guard))
861}
862
863fn block_template(destination: &str, workflow: Workflow) -> Result<(String, Vec<String>), RkError> {
866 match destination {
867 AGENTS_DESTINATION => Ok((
868 routing_block(workflow)?,
869 vec![AGENTS_BLOCK.to_owned(), routing_line(workflow).to_owned()],
870 )),
871 GLOSSARY_DESTINATION => Ok((glossary_block()?, vec![GLOSSARY_BLOCK.to_owned()])),
872 HOOKS_DESTINATION => {
873 let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
874 if workflow == Workflow::Worktree {
875 sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
876 }
877 Ok((hooks_block(workflow)?, sources))
878 }
879 other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
880 }
881}
882
883#[must_use]
885pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
886 match destination {
887 AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
888 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
889 _ => None,
890 }
891}
892
893#[must_use]
896pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
897 let start = text.find(begin)?;
898 let stop = text[start..].find(end)? + start + end.len();
899 Some(&text[start..stop])
900}
901
902#[must_use]
909pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
910 let block = block.as_bytes();
911 let Some(text) = existing else {
912 return [block, b"\n"].concat();
913 };
914 if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
918 && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
919 {
920 let stop = start + offset + BLOCK_END.len();
921 return [&text[..start], block, &text[stop..]].concat();
922 }
923 let mut out = Vec::with_capacity(text.len() + block.len() + 3);
927 out.extend_from_slice(text);
928 if !text.ends_with(b"\n") {
929 out.push(b'\n');
930 }
931 out.push(b'\n');
932 out.extend_from_slice(block);
933 out.push(b'\n');
934 out
935}
936
937pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
950 let Some(text) = existing else {
951 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
952 };
953 if let Some(defect) = hooks_marker_defect(text) {
954 return Err(defect);
955 }
956 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
957 return Ok(text.replacen(found, block, 1));
958 }
959 let mut out = String::with_capacity(text.len() + block.len() + 1);
960 let mut placed = false;
961 for line in text.split_inclusive('\n') {
962 out.push_str(line);
963 if !placed && line.trim_end() == "repos:" {
964 if !out.ends_with('\n') {
965 out.push('\n');
966 }
967 out.push_str(block);
968 out.push('\n');
969 placed = true;
970 }
971 }
972 if placed {
973 Ok(out)
974 } else {
975 Err(format!(
976 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
977 ))
978 }
979}
980
981#[must_use]
991pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
992 let (begin, end) = block_markers(destination)?;
993 let begins = text.matches(begin).count();
994 let ends = text.matches(end).count();
995 if begins > 1 || ends > 1 {
996 return Some(format!(
997 "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
998 ));
999 }
1000 match (text.find(begin), text.find(end)) {
1001 (Some(begin), Some(end)) if end > begin => None,
1002 (None, None) => None,
1003 _ => Some(format!(
1004 "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
1005 )),
1006 }
1007}
1008
1009#[must_use]
1012pub fn hooks_marker_defect(text: &str) -> Option<String> {
1013 marker_defect(HOOKS_DESTINATION, text)
1014}
1015
1016fn propose_document(
1020 destination: &str,
1021 existing: Option<&[u8]>,
1022 region: &[u8],
1023) -> Result<Vec<u8>, String> {
1024 let block = String::from_utf8_lossy(region).into_owned();
1029 if let Some(text) = existing
1030 && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
1031 {
1032 return Err(defect);
1033 }
1034 if destination == HOOKS_DESTINATION {
1035 let text = match existing {
1039 None => None,
1040 Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
1041 format!(
1042 "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
1043 )
1044 })?),
1045 };
1046 return splice_hooks_block(text, &block).map(String::into_bytes);
1047 }
1048 Ok(splice_marked_block(existing, &block))
1049}
1050
1051#[must_use]
1063pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
1064 let Some(text) = shape.cargo_toml.as_deref() else {
1065 return Some(
1066 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
1067 );
1068 };
1069 let Ok(table) = text.parse::<toml::Table>() else {
1070 return Some(
1071 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
1072 );
1073 };
1074 if !table.contains_key("package") {
1075 return Some(
1076 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
1077 );
1078 }
1079 if !shape.cargo_lock {
1080 return Some(
1081 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
1082 );
1083 }
1084 let implicit_bin = shape.main_rs
1085 && table
1086 .get("package")
1087 .and_then(toml::Value::as_table)
1088 .and_then(|package| package.get("autobins"))
1089 .and_then(toml::Value::as_bool)
1090 != Some(false);
1091 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
1092 if explicit_bins.is_none() && !implicit_bin {
1093 return Some(
1094 "the target declares no binary — no effective src/main.rs and no [[bin]] entry — and the seed flake's smoke check runs one; no Nix file lands".to_owned(),
1095 );
1096 }
1097 if let Some(bins) = explicit_bins {
1103 let required = bins
1104 .first()
1105 .and_then(toml::Value::as_table)
1106 .and_then(|bin| bin.get("required-features"))
1107 .and_then(toml::Value::as_array);
1108 if let Some(required) = required {
1109 let enabled = default_features(&table);
1110 let missing = required
1111 .iter()
1112 .filter_map(toml::Value::as_str)
1113 .any(|feature| !enabled.contains(feature));
1114 if missing {
1115 return Some(
1116 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
1117 );
1118 }
1119 }
1120 }
1121 None
1122}
1123
1124fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
1127 let edge = format!("dep:{name}");
1128 features.values().any(|list| {
1129 list.as_array().is_some_and(|entries| {
1130 entries
1131 .iter()
1132 .filter_map(toml::Value::as_str)
1133 .any(|entry| entry == edge)
1134 })
1135 })
1136}
1137
1138fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
1141 ["dependencies", "build-dependencies"]
1142 .iter()
1143 .any(|section| {
1144 table
1145 .get(*section)
1146 .and_then(toml::Value::as_table)
1147 .and_then(|dependencies| dependencies.get(name))
1148 .and_then(toml::Value::as_table)
1149 .and_then(|dependency| dependency.get("optional"))
1150 .and_then(toml::Value::as_bool)
1151 == Some(true)
1152 })
1153}
1154
1155fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
1162 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
1163 return std::collections::BTreeSet::new();
1164 };
1165 let mut enabled = std::collections::BTreeSet::new();
1166 let mut queue = vec!["default".to_owned()];
1167 while let Some(name) = queue.pop() {
1168 if !enabled.insert(name.clone()) {
1169 continue;
1170 }
1171 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
1172 for implied in implies.iter().filter_map(toml::Value::as_str) {
1173 if implied.starts_with("dep:") || implied.contains("?/") {
1174 continue;
1178 }
1179 if let Some((package, _)) = implied.split_once('/') {
1180 let feature_exists =
1188 features.contains_key(package) || !dep_edge_suppresses(features, package);
1189 if is_optional_dependency(table, package) && feature_exists {
1190 queue.push(package.to_owned());
1191 }
1192 } else {
1193 queue.push(implied.to_owned());
1194 }
1195 }
1196 }
1197 }
1198 enabled
1199}
1200
1201#[must_use]
1209pub fn flake_pair_withheld(
1210 flake_recorded: bool,
1211 flake_nix_present: bool,
1212 flake_lock_present: bool,
1213) -> Option<String> {
1214 if flake_recorded {
1215 return None;
1216 }
1217 let present: Vec<&str> = [
1218 ("flake.nix", flake_nix_present),
1219 ("flake.lock", flake_lock_present),
1220 ]
1221 .into_iter()
1222 .filter_map(|(name, present)| present.then_some(name))
1223 .collect();
1224 if present.is_empty() {
1225 return None;
1226 }
1227 Some(format!(
1228 "the target already carries {}; its flake pair stays its own",
1229 present.join(" and ")
1230 ))
1231}
1232
1233#[must_use]
1243pub fn nix_withholding(
1244 nix: bool,
1245 evidence: &TargetEvidence,
1246) -> Option<(&'static [&'static str], String)> {
1247 if !nix {
1248 return None;
1249 }
1250 if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
1251 return Some((&NIX_DESTINATIONS[..], reason));
1252 }
1253 flake_pair_withheld(
1254 evidence.flake_recorded,
1255 evidence.flake_nix_present,
1256 evidence.flake_lock_present,
1257 )
1258 .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263 use super::{
1264 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
1265 CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
1266 HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
1267 select_pair,
1268 };
1269 use crate::landing::{Params, Style};
1270
1271 fn supported_shape() -> CrateShape {
1273 CrateShape {
1274 cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
1275 cargo_lock: true,
1276 main_rs: true,
1277 }
1278 }
1279
1280 fn input(evidence: TargetEvidence) -> ProjectionInput {
1281 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
1282 params.set_nix_for_test(true);
1283 ProjectionInput { params, evidence }
1284 }
1285
1286 fn compute(evidence: TargetEvidence) -> Projection {
1287 Projection::compute(&input(evidence)).expect("the embedded pair projects")
1288 }
1289
1290 fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
1291 projection
1292 .candidates
1293 .iter()
1294 .find(|candidate| candidate.destination == destination)
1295 .expect("the destination projects")
1296 }
1297
1298 fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
1300 let text = String::from_utf8_lossy(bytes);
1301 let start = text.find(begin).expect("the begin marker is present");
1302 let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
1303 (bytes[..start].to_vec(), bytes[stop..].to_vec())
1304 }
1305
1306 #[test]
1307 fn equal_projection_inputs_yield_byte_identical_projections() {
1308 let mut documents = std::collections::BTreeMap::new();
1309 documents.insert(
1310 AGENTS_DESTINATION.to_owned(),
1311 b"# Widget\n\nOwn rules.\n".to_vec(),
1312 );
1313 let evidence = TargetEvidence {
1314 documents,
1315 crate_shape: supported_shape(),
1316 ..TargetEvidence::default()
1317 };
1318 let first = input(evidence.clone());
1319 let second = input(evidence);
1320 assert_eq!(first, second, "the inputs are values and compare equal");
1321 let a = Projection::compute(&first).expect("the pair projects");
1322 let b = Projection::compute(&second).expect("the pair projects");
1323 assert_eq!(a.candidates.len(), b.candidates.len());
1324 for (x, y) in a.candidates.iter().zip(&b.candidates) {
1325 assert_eq!(x.destination, y.destination);
1326 assert_eq!(x.kind, y.kind);
1327 assert_eq!(x.placement, y.placement);
1328 assert_eq!(x.bytes, y.bytes, "{}", x.destination);
1329 assert_eq!(x.region, y.region, "{}", x.destination);
1330 assert_eq!(x.sources, y.sources, "{}", x.destination);
1331 }
1332 assert_eq!(a, b);
1333 let destinations: Vec<&str> = a
1334 .candidates
1335 .iter()
1336 .map(|candidate| candidate.destination.as_str())
1337 .collect();
1338 let mut sorted = destinations.clone();
1339 sorted.sort_unstable();
1340 assert_eq!(destinations, sorted, "candidates sort by destination");
1341 assert!(a.omissions.is_empty(), "{:?}", a.omissions);
1342 assert!(a.collisions.is_empty(), "{:?}", a.collisions);
1343 }
1344
1345 #[test]
1351 fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
1352 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
1353 let text = std::fs::read_to_string(&path).expect("the source reads");
1354 let production = text.split("#[cfg(test)]").next().unwrap_or("");
1355 let needles = [
1356 "std::fs",
1357 "std::env",
1358 "std::process",
1359 "std::time",
1360 "SystemTime",
1361 "Instant",
1362 "std::net",
1363 "Command::new",
1364 "registry::",
1365 "curl",
1366 "reqwest",
1367 "blob(",
1368 ];
1369 let mut hits = Vec::new();
1370 for (index, line) in production.lines().enumerate() {
1371 if line.trim_start().starts_with("//") {
1372 continue;
1373 }
1374 for needle in needles {
1375 if line.contains(needle) {
1376 hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
1377 }
1378 }
1379 }
1380 assert!(
1381 hits.is_empty(),
1382 "the projection reads beyond its inputs: {hits:?}"
1383 );
1384 }
1385
1386 #[test]
1387 #[allow(
1388 clippy::too_many_lines,
1389 reason = "one test walks the three marked destinations and the three unmarked shapes"
1390 )]
1391 fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
1392 let agents_before = "# Widget\n\nOperator prose above.\n\n";
1393 let agents_after = "\n\n## Our rules\n\nOperator prose below. \n";
1394 let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
1395 let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
1396 let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
1397 let hooks_after =
1398 "\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1399 let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
1400 let mut documents = std::collections::BTreeMap::new();
1401 documents.insert(
1402 AGENTS_DESTINATION.to_owned(),
1403 format!(
1404 "{agents_before}{}{agents_after}",
1405 stale(BLOCK_BEGIN, BLOCK_END)
1406 )
1407 .into_bytes(),
1408 );
1409 documents.insert(
1410 GLOSSARY_DESTINATION.to_owned(),
1411 format!(
1412 "{glossary_before}{}{glossary_after}",
1413 stale(BLOCK_BEGIN, BLOCK_END)
1414 )
1415 .into_bytes(),
1416 );
1417 documents.insert(
1418 HOOKS_DESTINATION.to_owned(),
1419 format!(
1420 "{hooks_before}{}{hooks_after}",
1421 stale(HOOKS_BEGIN, HOOKS_END)
1422 )
1423 .into_bytes(),
1424 );
1425 let projection = compute(TargetEvidence {
1426 documents: documents.clone(),
1427 crate_shape: supported_shape(),
1428 ..TargetEvidence::default()
1429 });
1430 assert!(
1431 projection.collisions.is_empty(),
1432 "{:?}",
1433 projection.collisions
1434 );
1435 for (destination, before, after) in [
1436 (AGENTS_DESTINATION, agents_before, agents_after),
1437 (GLOSSARY_DESTINATION, glossary_before, glossary_after),
1438 (HOOKS_DESTINATION, hooks_before, hooks_after),
1439 ] {
1440 let candidate = candidate(&projection, destination);
1441 let Placement::Region { begin, end } = candidate.placement else {
1442 panic!("{destination} is a region");
1443 };
1444 let region = candidate
1445 .region
1446 .as_deref()
1447 .expect("a region carries its block");
1448 let (head, tail) = outside(&candidate.bytes, begin, end);
1449 assert_eq!(
1450 head,
1451 before.as_bytes(),
1452 "{destination}: bytes before the markers"
1453 );
1454 assert_eq!(
1455 tail,
1456 after.as_bytes(),
1457 "{destination}: bytes after the markers"
1458 );
1459 let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
1460 assert_eq!(
1461 inside, region,
1462 "{destination}: the region is the rendered block"
1463 );
1464 let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
1465 assert_eq!(head, existing_head);
1466 assert_eq!(tail, existing_tail);
1467 }
1468
1469 let own_hooks =
1473 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1474 let own_agents = "# Widget\n\nOwn rules.";
1475 let mut documents = std::collections::BTreeMap::new();
1476 documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
1477 documents.insert(
1478 AGENTS_DESTINATION.to_owned(),
1479 own_agents.as_bytes().to_vec(),
1480 );
1481 let projection = compute(TargetEvidence {
1482 documents,
1483 crate_shape: supported_shape(),
1484 ..TargetEvidence::default()
1485 });
1486 assert!(
1487 projection.collisions.is_empty(),
1488 "{:?}",
1489 projection.collisions
1490 );
1491 let hooks = candidate(&projection, HOOKS_DESTINATION);
1492 let hooks_text = String::from_utf8_lossy(&hooks.bytes);
1493 let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
1494 assert!(
1495 hooks_text.starts_with(&format!(
1496 "repos:\n{region}\n - repo: https://example.com/own"
1497 )),
1498 "{hooks_text}"
1499 );
1500 assert!(!hooks_text.contains(HOOK_TYPES_LINE));
1501 let agents = candidate(&projection, AGENTS_DESTINATION);
1502 assert!(agents.bytes.starts_with(own_agents.as_bytes()));
1503 assert_eq!(
1504 extract_block(
1505 &String::from_utf8_lossy(&agents.bytes),
1506 BLOCK_BEGIN,
1507 BLOCK_END
1508 )
1509 .map(str::as_bytes),
1510 agents.region.as_deref()
1511 );
1512 let glossary = candidate(&projection, GLOSSARY_DESTINATION);
1513 let region = glossary.region.as_deref().expect("a region");
1514 assert_eq!(
1515 glossary.bytes,
1516 [region, b"\n"].concat(),
1517 "an absent file is fresh"
1518 );
1519 }
1520
1521 #[test]
1527 fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
1528 let mut documents = std::collections::BTreeMap::new();
1529 let mut invalid = b"repos:\n# own \xff above\n".to_vec();
1530 invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
1531 invalid.extend_from_slice(b" - repo: local \xff below\n");
1532 documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
1533 let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
1534 agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
1535 agents.extend_from_slice(b"r\xe9sum\xe9\n");
1536 documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
1537 let projection = compute(TargetEvidence {
1538 documents,
1539 crate_shape: supported_shape(),
1540 ..TargetEvidence::default()
1541 });
1542 let collided: Vec<&str> = projection
1543 .collisions
1544 .iter()
1545 .map(|c| c.destination.as_str())
1546 .collect();
1547 assert_eq!(collided, [HOOKS_DESTINATION]);
1548 assert!(
1549 projection.collisions[0].reason.contains("not UTF-8"),
1550 "{}",
1551 projection.collisions[0].reason
1552 );
1553 assert!(
1554 !projection
1555 .candidates
1556 .iter()
1557 .any(|c| c.destination == HOOKS_DESTINATION),
1558 "a colliding destination projects no candidate"
1559 );
1560 let agents = candidate(&projection, AGENTS_DESTINATION);
1561 assert!(
1562 agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
1563 "{:?}",
1564 agents.bytes
1565 );
1566 assert!(
1567 agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
1568 "{:?}",
1569 agents.bytes
1570 );
1571 assert!(
1572 !agents.bytes.contains(&0xEF),
1573 "a replacement character landed"
1574 );
1575
1576 let mut documents = std::collections::BTreeMap::new();
1577 let valid =
1578 format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n - repo: local\n");
1579 documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
1580 let projection = compute(TargetEvidence {
1581 documents,
1582 crate_shape: supported_shape(),
1583 ..TargetEvidence::default()
1584 });
1585 assert!(
1586 projection.collisions.is_empty(),
1587 "{:?}",
1588 projection.collisions
1589 );
1590 let hooks = candidate(&projection, HOOKS_DESTINATION);
1591 let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
1592 assert!(text.starts_with("repos:\n# own above\n"), "{text}");
1593 assert!(text.ends_with("\n - repo: local\n"), "{text}");
1594 assert!(!text.contains("stale"), "the region is replaced: {text}");
1595 }
1596
1597 #[test]
1601 fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
1602 let files: Vec<(String, &[u8])> = vec![
1603 ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
1604 ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
1605 ];
1606 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
1607 .expect_err("a whole file at a block destination refuses");
1608 let text = err.to_string();
1609 assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
1610 assert!(text.contains(super::AGENTS_BLOCK), "{text}");
1611 assert!(text.contains(super::AGENTS_LINE_WORKTREE), "{text}");
1612 assert!(text.contains("embedded sources are defective"), "{text}");
1613 }
1614
1615 #[test]
1616 fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
1617 {
1618 let files: Vec<(String, &[u8])> = vec![
1619 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
1620 ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
1621 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
1622 ];
1623 let err = select_pair(&files, "rust", "github").expect_err("a doubled destination refuses");
1624 let text = err.to_string();
1625 assert!(
1626 text.contains("snippets/_shared/github/SECURITY.md"),
1627 "{text}"
1628 );
1629 assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
1630 assert!(text.contains("embedded sources are defective"), "{text}");
1631
1632 let clean: Vec<(String, &[u8])> = vec![
1633 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
1634 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
1635 ];
1636 let selected = select_pair(&clean, "rust", "github").expect("a clean list selects");
1637 let destinations: Vec<&str> = selected.iter().map(|s| s.destination.as_str()).collect();
1638 assert_eq!(destinations, ["SECURITY.md", "release-plz.toml"]);
1639 assert_eq!(selected[0].source, "snippets/_shared/github/SECURITY.md");
1640 let err = select_pair(&clean, "_shared", "github").expect_err("the shared zone is no tech");
1641 assert!(!err.to_string().contains("bindings are: _shared"), "{err}");
1642 let err = select_pair(&clean, "rust", "gitlab").expect_err("an unshipped pair refuses");
1643 assert!(err.to_string().contains("rust, github"), "{err}");
1644
1645 let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
1646 let unmatched = format!("repos:\n{HOOKS_BEGIN}\n - repo: local\n");
1647 let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
1648 let mut documents = std::collections::BTreeMap::new();
1649 documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
1650 documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
1651 documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
1652 let projection = compute(TargetEvidence {
1653 documents,
1654 crate_shape: supported_shape(),
1655 ..TargetEvidence::default()
1656 });
1657 let mut collided: Vec<&str> = projection
1658 .collisions
1659 .iter()
1660 .map(|Collision { destination, .. }| destination.as_str())
1661 .collect();
1662 collided.sort_unstable();
1663 let mut expected = BLOCK_DESTINATIONS.to_vec();
1664 expected.sort_unstable();
1665 assert_eq!(collided, expected);
1666 for collision in &projection.collisions {
1667 assert!(
1668 collision.reason.contains(&collision.destination),
1669 "{collision:?}"
1670 );
1671 assert!(
1672 !projection
1673 .candidates
1674 .iter()
1675 .any(|candidate| candidate.destination == collision.destination),
1676 "{} collided and still projects",
1677 collision.destination
1678 );
1679 }
1680 let agents = projection
1681 .collisions
1682 .iter()
1683 .find(|c| c.destination == AGENTS_DESTINATION)
1684 .expect("the doubled document collides");
1685 assert!(agents.reason.contains("more than one"), "{}", agents.reason);
1686 let hooks = projection
1687 .collisions
1688 .iter()
1689 .find(|c| c.destination == HOOKS_DESTINATION)
1690 .expect("the unmatched document collides");
1691 assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
1692 }
1693}