1pub mod evidence;
33
34use std::collections::BTreeMap;
35
36use serde::{Deserialize, Serialize};
37
38use crate::embedded;
39use crate::error::RkError;
40use crate::landing::{Params, Workflow};
41
42#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ProjectionInput {
46 pub params: Params,
48 pub evidence: TargetEvidence,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
55pub struct TargetEvidence {
56 pub documents: BTreeMap<String, Vec<u8>>,
60 pub crate_shape: CrateShape,
62 pub flake_nix_present: bool,
64 pub flake_lock_present: bool,
66 pub flake_recorded: bool,
69}
70
71impl TargetEvidence {
72 #[must_use]
74 pub fn document(&self, destination: &str) -> Option<&[u8]> {
75 self.documents.get(destination).map(Vec::as_slice)
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Default)]
82pub struct CrateShape {
83 pub cargo_toml: Option<String>,
85 pub cargo_lock: bool,
87 pub main_rs: bool,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct Projection {
99 pub candidates: Vec<Candidate>,
101 pub omissions: Vec<Omission>,
105 pub collisions: Vec<Collision>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct Candidate {
113 pub destination: String,
115 pub kind: Kind,
117 pub placement: Placement,
119 pub bytes: Vec<u8>,
123 pub region: Option<Vec<u8>>,
126 pub sources: Vec<String>,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum Placement {
134 Whole,
136 Region {
139 begin: &'static str,
141 end: &'static str,
143 },
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct Omission {
149 pub destination: String,
151 pub reason: String,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct Collision {
158 pub destination: String,
160 pub reason: String,
162}
163
164impl Projection {
165 pub fn compute(input: &ProjectionInput) -> Result<Self, RkError> {
175 Self::compute_over(&embedded_snippets(), input)
176 }
177
178 fn compute_over(files: &[(String, &[u8])], input: &ProjectionInput) -> Result<Self, RkError> {
182 let params = &input.params;
183 let evidence = &input.evidence;
184 let mut candidates = Vec::new();
185 for selected in select_pair(files, params.tech(), params.forge())? {
186 if !params.nix() && NIX_DESTINATIONS.contains(&selected.destination.as_str()) {
187 continue;
188 }
189 let kind = kind_of(&selected.destination).ok_or_else(|| {
190 anyhow::anyhow!(
191 "the payload does not classify {}; the kind table is stale",
192 selected.destination
193 )
194 })?;
195 let bytes = match kind {
196 Kind::Rendered => render(selected.payload, params),
197 Kind::Seeded | Kind::State => selected.payload.to_vec(),
198 };
199 candidates.push(Candidate {
200 destination: selected.destination,
201 kind,
202 placement: Placement::Whole,
203 bytes,
204 region: None,
205 sources: vec![selected.source.to_owned()],
206 });
207 }
208 let mut collisions = Vec::new();
209 for destination in BLOCK_DESTINATIONS {
210 let (template, sources) = block_template(destination, params.workflow())?;
211 if let Some(whole) = candidates
212 .iter()
213 .find(|candidate| candidate.destination == destination)
214 {
215 return Err(anyhow::anyhow!(
216 "{destination} is both a whole file from {} and a marked region from {}; the payload is defective",
217 whole.sources.join(", "),
218 sources.join(", ")
219 )
220 .into());
221 }
222 let region = render(template.as_bytes(), params);
223 let (begin, end) = block_markers(destination).ok_or_else(|| {
224 anyhow::anyhow!("{destination} is a block destination with no markers")
225 })?;
226 match propose_document(destination, evidence.document(destination), ®ion) {
227 Ok(bytes) => candidates.push(Candidate {
228 destination: destination.to_owned(),
229 kind: Kind::Rendered,
230 placement: Placement::Region { begin, end },
231 bytes,
232 region: Some(region),
233 sources,
234 }),
235 Err(reason) => collisions.push(Collision {
236 destination: destination.to_owned(),
237 reason,
238 }),
239 }
240 }
241 let mut omissions = Vec::new();
242 if let Some((set, reason)) = nix_withholding(params.nix(), evidence) {
243 candidates.retain(|candidate| {
244 if set.contains(&candidate.destination.as_str()) {
245 omissions.push(Omission {
246 destination: candidate.destination.clone(),
247 reason: reason.clone(),
248 });
249 false
250 } else {
251 true
252 }
253 });
254 }
255 candidates.sort_by(|a, b| a.destination.cmp(&b.destination));
256 omissions.sort_by(|a, b| a.destination.cmp(&b.destination));
257 Ok(Self {
258 candidates,
259 omissions,
260 collisions,
261 })
262 }
263}
264
265fn embedded_snippets() -> Vec<(String, &'static [u8])> {
268 embedded::walk(&embedded::SNIPPETS)
269 .into_iter()
270 .map(|(path, bytes)| (format!("snippets/{path}"), bytes))
271 .collect()
272}
273
274#[must_use]
277pub fn supported_pairs() -> Vec<(String, String)> {
278 let mut pairs = Vec::new();
279 for (path, _) in embedded_snippets() {
280 let Some(rest) = path.strip_prefix("snippets/") else {
281 continue;
282 };
283 let mut segments = rest.split('/');
284 let (Some(tech), Some(forge), Some(_)) =
285 (segments.next(), segments.next(), segments.next())
286 else {
287 continue;
288 };
289 if tech.starts_with('_') {
290 continue;
291 }
292 let pair = (tech.to_owned(), forge.to_owned());
293 if !pairs.contains(&pair) {
294 pairs.push(pair);
295 }
296 }
297 pairs
298}
299
300#[derive(Debug)]
303pub struct Selected<'a, T> {
304 pub destination: String,
306 pub source: &'a str,
308 pub payload: &'a T,
310}
311
312pub fn select_pair<'a, T>(
326 files: &'a [(String, T)],
327 tech: &str,
328 forge: &str,
329) -> Result<Vec<Selected<'a, T>>, RkError> {
330 let mut techs: Vec<&str> = Vec::new();
331 for (path, _) in files {
332 if let Some(rest) = path.strip_prefix("snippets/")
333 && let Some((dir, _)) = rest.split_once('/')
334 && !dir.starts_with('_')
335 && !techs.contains(&dir)
336 {
337 techs.push(dir);
338 }
339 }
340 if tech.starts_with('_') || !techs.contains(&tech) {
341 return Err(RkError::Usage(format!(
342 "unknown tech '{tech}'; the bindings are: {}",
343 techs.join(", ")
344 )));
345 }
346 let pair = format!("snippets/{tech}/{forge}/");
347 if !files.iter().any(|(path, _)| path.starts_with(&pair)) {
348 let mut known: Vec<String> = Vec::new();
349 for tech in &techs {
350 let prefix = format!("snippets/{tech}/");
351 for (path, _) in files {
352 if let Some(rest) = path.strip_prefix(&prefix)
353 && let Some((forge, _)) = rest.split_once('/')
354 {
355 let entry = format!("{tech}, {forge}");
356 if !known.contains(&entry) {
357 known.push(entry);
358 }
359 }
360 }
361 }
362 return Err(RkError::Usage(format!(
363 "the pair ({tech}, {forge}) has no landable files; the supported pairs are: {}",
364 known.join("; ")
365 )));
366 }
367 let shared = format!("snippets/_shared/{forge}/");
368 let mut out: Vec<Selected<'a, T>> = Vec::new();
369 for zone in [&shared, &pair] {
370 for (path, payload) in files {
371 let Some(rel) = path.strip_prefix(zone.as_str()) else {
372 continue;
373 };
374 if let Some(existing) = out.iter().find(|selected| selected.destination == rel) {
375 return Err(anyhow::anyhow!(
376 "the shared zone and the pair ({tech}, {forge}) both ship {rel}: {} and {path}; the payload is defective",
377 existing.source
378 )
379 .into());
380 }
381 out.push(Selected {
382 destination: rel.to_owned(),
383 source: path,
384 payload,
385 });
386 }
387 }
388 Ok(out)
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
393#[serde(rename_all = "lowercase")]
394pub enum Kind {
395 Rendered,
398 Seeded,
401 State,
404}
405
406impl Kind {
407 #[must_use]
409 pub const fn as_str(self) -> &'static str {
410 match self {
411 Self::Rendered => "rendered",
412 Self::Seeded => "seeded",
413 Self::State => "state",
414 }
415 }
416}
417
418const KINDS: [(&str, Kind); 16] = [
424 (".github/workflows/release-plz.yml", Kind::Rendered),
425 (".github/workflows/release-please.yml", Kind::Rendered),
426 (".github/workflows/release.yml", Kind::Rendered),
427 (".github/workflows/pr-title.yml", Kind::Rendered),
428 (".gitlab-ci.yml", Kind::Rendered),
429 ("SECURITY.md", Kind::Rendered),
430 (".gitlab/ci/mr-title.yml", Kind::Rendered),
431 ("release-plz.toml", Kind::Seeded),
432 ("dist-workspace.toml", Kind::Seeded),
433 ("release-please-config.json", Kind::Seeded),
434 ("cliff.toml", Kind::Seeded),
435 ("nix/package.nix", Kind::Seeded),
436 ("flake.nix", Kind::Seeded),
437 (".release-please-manifest.json", Kind::State),
438 ("VERSION", Kind::State),
439 ("flake.lock", Kind::State),
440];
441
442pub const NIX_DESTINATIONS: [&str; 3] = ["nix/package.nix", "flake.nix", "flake.lock"];
456
457pub const NIX_WITHHOLDABLE: [&str; 2] = ["flake.nix", "flake.lock"];
463
464#[must_use]
467pub fn kind_of(destination: &str) -> Option<Kind> {
468 if BLOCK_DESTINATIONS.contains(&destination) {
469 return Some(Kind::Rendered);
470 }
471 KINDS
472 .iter()
473 .find(|(name, _)| *name == destination)
474 .map(|(_, kind)| *kind)
475}
476
477pub fn destinations() -> impl Iterator<Item = &'static str> {
482 KINDS
483 .iter()
484 .map(|(name, _)| *name)
485 .chain(BLOCK_DESTINATIONS)
486}
487
488pub const OWNER_TOKEN: &[u8] = b"OWNER";
495
496pub const REPO_PLACEHOLDER: &str = "OWNER";
501
502pub const REPO_TOKEN: &[u8] = b"RK_REPO";
504
505pub const SCOPE_SHAPE_TOKEN: &[u8] = b"RK_SCOPE_SHAPE";
507
508pub const STYLE_TOKEN: &[u8] = b"RK_STYLE";
511
512pub const TRUNK_BRANCH_TOKEN: &[u8] = b"RK_TRUNK_BRANCH";
516
517pub const LINE_PREFIX_TOKEN: &[u8] = b"RK_LINE_PREFIX";
520
521pub const LINE_PREFIX_RE_TOKEN: &[u8] = b"RK_LINE_PREFIX_RE";
527
528pub const SECURITY_SPANS: [(&[u8], &[u8]); 3] = [
539 (
540 b"<!--RK_SECURITY_CONTACT_BEGIN-->",
541 b"<!--RK_SECURITY_CONTACT_END-->",
542 ),
543 (
544 b"<!--RK_SECURITY_RESPONSE_BEGIN-->",
545 b"<!--RK_SECURITY_RESPONSE_END-->",
546 ),
547 (
548 b"<!--RK_SECURITY_DEADLINE_BEGIN-->",
549 b"<!--RK_SECURITY_DEADLINE_END-->",
550 ),
551];
552
553fn acknowledgment(response: &str) -> String {
556 format!("Maintainers acknowledge a report within {response}.")
557}
558
559const DISCLOSURE_ONLY: &[u8] = b"This policy commits to no disclosure deadline.";
563
564fn security_replacements(params: &Params) -> [Option<Vec<u8>>; 3] {
567 let contact = (!params.security_contact().is_empty())
568 .then(|| params.security_contact().as_bytes().to_vec());
569 let promised = params.security_response() != crate::config::RESPONSE_DEFAULT;
570 [
571 contact,
572 promised.then(|| acknowledgment(params.security_response()).into_bytes()),
573 promised.then(|| DISCLOSURE_ONLY.to_vec()),
574 ]
575}
576
577fn replace_span(baseline: &[u8], begin: &[u8], end: &[u8], value: Option<&[u8]>) -> Vec<u8> {
583 let ordered = find(baseline, begin)
584 .zip(find(baseline, end))
585 .filter(|(start, stop)| stop > start);
586 let Some((start, stop)) = ordered else {
587 return baseline.to_vec();
588 };
589 let mut out = Vec::with_capacity(baseline.len());
590 out.extend_from_slice(&baseline[..start]);
591 out.extend_from_slice(value.unwrap_or_else(|| &baseline[start + begin.len()..stop]));
592 out.extend_from_slice(&baseline[stop + end.len()..]);
593 out
594}
595
596#[must_use]
614pub fn render(baseline: &[u8], params: &Params) -> Vec<u8> {
615 let repo = params.repo();
616 let owner = repo.split('/').next().unwrap_or(repo);
617 let mut out = substitute(baseline, OWNER_TOKEN, owner.as_bytes());
618 if let Some(style) = params.style() {
619 out = substitute(&out, STYLE_TOKEN, style.as_str().as_bytes());
620 }
621 out = substitute(&out, SCOPE_SHAPE_TOKEN, SCOPE_SHAPE.as_bytes());
622 out = substitute(&out, TRUNK_BRANCH_TOKEN, params.trunk().as_bytes());
623 let escaped = params.line_prefix().replace('/', "\\/");
624 out = substitute(&out, LINE_PREFIX_RE_TOKEN, escaped.as_bytes());
625 out = substitute(&out, LINE_PREFIX_TOKEN, params.line_prefix().as_bytes());
626 out = substitute(&out, REPO_TOKEN, repo.as_bytes());
627 for ((begin, end), value) in SECURITY_SPANS.iter().zip(security_replacements(params)) {
628 out = replace_span(&out, begin, end, value.as_deref());
629 }
630 out
631}
632
633#[must_use]
635pub fn substitute(baseline: &[u8], token: &[u8], value: &[u8]) -> Vec<u8> {
636 let mut out = Vec::with_capacity(baseline.len());
637 let mut rest = baseline;
638 while let Some(at) = find(rest, token) {
639 out.extend_from_slice(&rest[..at]);
640 out.extend_from_slice(value);
641 rest = &rest[at + token.len()..];
642 }
643 out.extend_from_slice(rest);
644 out
645}
646
647fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
649 haystack
650 .windows(needle.len())
651 .position(|window| window == needle)
652}
653
654pub const AGENTS_DESTINATION: &str = "AGENTS.md";
656
657pub const BLOCK_BEGIN: &str = "<!-- BEGIN release-kit -->";
659
660pub const BLOCK_END: &str = "<!-- END release-kit -->";
662
663pub const GLOSSARY_DESTINATION: &str = "GLOSSARY.md";
668
669pub const HOOKS_DESTINATION: &str = ".pre-commit-config.yaml";
671
672pub const BLOCK_DESTINATIONS: [&str; 3] =
678 [AGENTS_DESTINATION, GLOSSARY_DESTINATION, HOOKS_DESTINATION];
679
680pub const HOOKS_BEGIN: &str = "# BEGIN release-kit";
682
683pub const HOOKS_END: &str = "# END release-kit";
685
686pub const HOOK_TYPES_LINE: &str = "default_install_hook_types: [pre-commit, commit-msg, pre-push]";
690
691pub const AGENTS_BLOCK: &str = "blocks/agents-block.md.in";
693
694pub const GLOSSARY_BLOCK: &str = "blocks/glossary.md.in";
696
697pub const AGENTS_LINE_WORKTREE: &str = "blocks/agents-line-worktree.md.in";
699
700pub const AGENTS_LINE_BRANCHES: &str = "blocks/agents-line-branches.md.in";
702
703pub const PRE_COMMIT_BLOCK: &str = "blocks/pre-commit-block.yaml.in";
705
706pub const PRE_COMMIT_WORKTREE_GUARD: &str = "blocks/pre-commit-worktree-guard.yaml.in";
708
709#[must_use]
711pub const fn routing_line(workflow: Workflow) -> &'static str {
712 match workflow {
713 Workflow::Worktree => AGENTS_LINE_WORKTREE,
714 Workflow::Branches => AGENTS_LINE_BRANCHES,
715 }
716}
717
718pub fn embedded_block(path: &str) -> Result<&'static str, RkError> {
725 let name = path.strip_prefix("blocks/").unwrap_or(path);
726 let file = embedded::BLOCKS
727 .get_file(name)
728 .ok_or_else(|| anyhow::anyhow!("{path}: this binary embeds no such block"))?;
729 std::str::from_utf8(file.contents())
730 .map_err(|_| anyhow::anyhow!("{path}: a block is UTF-8").into())
731}
732
733#[must_use]
737pub fn authored(text: &str) -> &str {
738 text.strip_suffix('\n').unwrap_or(text)
739}
740
741pub 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[-/].+)$";
749
750pub const SCOPE_SHAPE: &str = "[a-z0-9._/-]+";
760
761#[must_use]
768pub fn scope_is_shaped(scope: &str) -> bool {
769 !scope.is_empty()
770 && scope.chars().all(|c| {
771 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '/' | '-')
772 })
773}
774
775#[must_use]
782pub fn compose_routing(template: &str, line: &str) -> String {
783 authored(template).replacen("RK_WORKFLOW_LINE", authored(line), 1)
784}
785
786#[must_use]
790pub fn compose_glossary(template: &str) -> String {
791 authored(template).to_owned()
792}
793
794#[must_use]
804pub fn compose_hooks(template: &str, guard: Option<&str>) -> String {
805 let (guard, skip) = guard.map_or_else(
806 || (String::new(), "no-commit-to-branch"),
807 |entry| {
808 (
809 format!("{}\n", authored(entry)),
810 "no-commit-to-branch,rk-worktree-location",
811 )
812 },
813 );
814 authored(template)
815 .replacen("RK_BRANCH_GRAMMAR", BRANCH_GRAMMAR, 1)
816 .replacen("RK_SWEEP_SKIP", skip, 1)
817 .replacen("RK_WORKTREE_GUARD", &guard, 1)
818}
819
820pub fn routing_block(workflow: Workflow) -> Result<String, RkError> {
827 Ok(compose_routing(
828 embedded_block(AGENTS_BLOCK)?,
829 embedded_block(routing_line(workflow))?,
830 ))
831}
832
833pub fn glossary_block() -> Result<String, RkError> {
839 Ok(compose_glossary(embedded_block(GLOSSARY_BLOCK)?))
840}
841
842pub fn hooks_block(workflow: Workflow) -> Result<String, RkError> {
849 let guard = match workflow {
850 Workflow::Worktree => Some(embedded_block(PRE_COMMIT_WORKTREE_GUARD)?),
851 Workflow::Branches => None,
852 };
853 Ok(compose_hooks(embedded_block(PRE_COMMIT_BLOCK)?, guard))
854}
855
856fn block_template(destination: &str, workflow: Workflow) -> Result<(String, Vec<String>), RkError> {
859 match destination {
860 AGENTS_DESTINATION => Ok((
861 routing_block(workflow)?,
862 vec![AGENTS_BLOCK.to_owned(), routing_line(workflow).to_owned()],
863 )),
864 GLOSSARY_DESTINATION => Ok((glossary_block()?, vec![GLOSSARY_BLOCK.to_owned()])),
865 HOOKS_DESTINATION => {
866 let mut sources = vec![PRE_COMMIT_BLOCK.to_owned()];
867 if workflow == Workflow::Worktree {
868 sources.push(PRE_COMMIT_WORKTREE_GUARD.to_owned());
869 }
870 Ok((hooks_block(workflow)?, sources))
871 }
872 other => Err(anyhow::anyhow!("{other} is not a block destination").into()),
873 }
874}
875
876#[must_use]
878pub fn block_markers(destination: &str) -> Option<(&'static str, &'static str)> {
879 match destination {
880 AGENTS_DESTINATION | GLOSSARY_DESTINATION => Some((BLOCK_BEGIN, BLOCK_END)),
881 HOOKS_DESTINATION => Some((HOOKS_BEGIN, HOOKS_END)),
882 _ => None,
883 }
884}
885
886#[must_use]
889pub fn extract_block<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
890 let start = text.find(begin)?;
891 let stop = text[start..].find(end)? + start + end.len();
892 Some(&text[start..stop])
893}
894
895#[must_use]
902pub fn splice_marked_block(existing: Option<&[u8]>, block: &str) -> Vec<u8> {
903 let block = block.as_bytes();
904 let Some(text) = existing else {
905 return [block, b"\n"].concat();
906 };
907 if let Some(start) = find(text, BLOCK_BEGIN.as_bytes())
911 && let Some(offset) = find(&text[start..], BLOCK_END.as_bytes())
912 {
913 let stop = start + offset + BLOCK_END.len();
914 return [&text[..start], block, &text[stop..]].concat();
915 }
916 let mut out = Vec::with_capacity(text.len() + block.len() + 3);
920 out.extend_from_slice(text);
921 if !text.ends_with(b"\n") {
922 out.push(b'\n');
923 }
924 out.push(b'\n');
925 out.extend_from_slice(block);
926 out.push(b'\n');
927 out
928}
929
930pub fn splice_hooks_block(existing: Option<&str>, block: &str) -> Result<String, String> {
943 let Some(text) = existing else {
944 return Ok(format!("{HOOK_TYPES_LINE}\n\nrepos:\n{block}\n"));
945 };
946 if let Some(defect) = hooks_marker_defect(text) {
947 return Err(defect);
948 }
949 if let Some(found) = extract_block(text, HOOKS_BEGIN, HOOKS_END) {
950 return Ok(text.replacen(found, block, 1));
951 }
952 let mut out = String::with_capacity(text.len() + block.len() + 1);
953 let mut placed = false;
954 for line in text.split_inclusive('\n') {
955 out.push_str(line);
956 if !placed && line.trim_end() == "repos:" {
957 if !out.ends_with('\n') {
958 out.push('\n');
959 }
960 out.push_str(block);
961 out.push('\n');
962 placed = true;
963 }
964 }
965 if placed {
966 Ok(out)
967 } else {
968 Err(format!(
969 "{HOOKS_DESTINATION} exists with no repos: line, so the hook block has nowhere to land"
970 ))
971 }
972}
973
974#[must_use]
984pub fn marker_defect(destination: &str, text: &str) -> Option<String> {
985 let (begin, end) = block_markers(destination)?;
986 let begins = text.matches(begin).count();
987 let ends = text.matches(end).count();
988 if begins > 1 || ends > 1 {
989 return Some(format!(
990 "{destination} carries more than one release-kit marker pair; release-kit owns exactly one block"
991 ));
992 }
993 match (text.find(begin), text.find(end)) {
994 (Some(begin), Some(end)) if end > begin => None,
995 (None, None) => None,
996 _ => Some(format!(
997 "{destination} carries an unmatched or misordered release-kit marker, so the block's extent is ambiguous"
998 )),
999 }
1000}
1001
1002#[must_use]
1005pub fn hooks_marker_defect(text: &str) -> Option<String> {
1006 marker_defect(HOOKS_DESTINATION, text)
1007}
1008
1009fn propose_document(
1013 destination: &str,
1014 existing: Option<&[u8]>,
1015 region: &[u8],
1016) -> Result<Vec<u8>, String> {
1017 let block = String::from_utf8_lossy(region).into_owned();
1022 if let Some(text) = existing
1023 && let Some(defect) = marker_defect(destination, &String::from_utf8_lossy(text))
1024 {
1025 return Err(defect);
1026 }
1027 if destination == HOOKS_DESTINATION {
1028 let text = match existing {
1032 None => None,
1033 Some(bytes) => Some(std::str::from_utf8(bytes).map_err(|_| {
1034 format!(
1035 "{destination} is not UTF-8, so the hook block has nowhere to land without rewriting the target's bytes"
1036 )
1037 })?),
1038 };
1039 return splice_hooks_block(text, &block).map(String::into_bytes);
1040 }
1041 Ok(splice_marked_block(existing, &block))
1042}
1043
1044#[must_use]
1056pub fn nix_unsupported_shape(shape: &CrateShape) -> Option<String> {
1057 let Some(text) = shape.cargo_toml.as_deref() else {
1058 return Some(
1059 "the target has no readable Cargo.toml, which the seeded package expression reads; no Nix file lands".to_owned(),
1060 );
1061 };
1062 let Ok(table) = text.parse::<toml::Table>() else {
1063 return Some(
1064 "the target's Cargo.toml does not parse, and the seeded package expression reads it; no Nix file lands".to_owned(),
1065 );
1066 };
1067 if !table.contains_key("package") {
1068 return Some(
1069 "the target's Cargo.toml has no [package] table; the seed supports a single crate, so no Nix file lands".to_owned(),
1070 );
1071 }
1072 if !shape.cargo_lock {
1073 return Some(
1074 "the target has no Cargo.lock, which the seeded package expression builds from; commit one, then opt in".to_owned(),
1075 );
1076 }
1077 let implicit_bin = shape.main_rs
1078 && table
1079 .get("package")
1080 .and_then(toml::Value::as_table)
1081 .and_then(|package| package.get("autobins"))
1082 .and_then(toml::Value::as_bool)
1083 != Some(false);
1084 let explicit_bins = table.get("bin").and_then(toml::Value::as_array);
1085 if explicit_bins.is_none() && !implicit_bin {
1086 return Some(
1087 "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(),
1088 );
1089 }
1090 if let Some(bins) = explicit_bins {
1096 let required = bins
1097 .first()
1098 .and_then(toml::Value::as_table)
1099 .and_then(|bin| bin.get("required-features"))
1100 .and_then(toml::Value::as_array);
1101 if let Some(required) = required {
1102 let enabled = default_features(&table);
1103 let missing = required
1104 .iter()
1105 .filter_map(toml::Value::as_str)
1106 .any(|feature| !enabled.contains(feature));
1107 if missing {
1108 return Some(
1109 "the target's first [[bin]] entry requires features a default build does not enable; no Nix file lands".to_owned(),
1110 );
1111 }
1112 }
1113 }
1114 None
1115}
1116
1117fn dep_edge_suppresses(features: &toml::Table, name: &str) -> bool {
1120 let edge = format!("dep:{name}");
1121 features.values().any(|list| {
1122 list.as_array().is_some_and(|entries| {
1123 entries
1124 .iter()
1125 .filter_map(toml::Value::as_str)
1126 .any(|entry| entry == edge)
1127 })
1128 })
1129}
1130
1131fn is_optional_dependency(table: &toml::Table, name: &str) -> bool {
1134 ["dependencies", "build-dependencies"]
1135 .iter()
1136 .any(|section| {
1137 table
1138 .get(*section)
1139 .and_then(toml::Value::as_table)
1140 .and_then(|dependencies| dependencies.get(name))
1141 .and_then(toml::Value::as_table)
1142 .and_then(|dependency| dependency.get("optional"))
1143 .and_then(toml::Value::as_bool)
1144 == Some(true)
1145 })
1146}
1147
1148fn default_features(table: &toml::Table) -> std::collections::BTreeSet<String> {
1155 let Some(features) = table.get("features").and_then(toml::Value::as_table) else {
1156 return std::collections::BTreeSet::new();
1157 };
1158 let mut enabled = std::collections::BTreeSet::new();
1159 let mut queue = vec!["default".to_owned()];
1160 while let Some(name) = queue.pop() {
1161 if !enabled.insert(name.clone()) {
1162 continue;
1163 }
1164 if let Some(implies) = features.get(&name).and_then(toml::Value::as_array) {
1165 for implied in implies.iter().filter_map(toml::Value::as_str) {
1166 if implied.starts_with("dep:") || implied.contains("?/") {
1167 continue;
1171 }
1172 if let Some((package, _)) = implied.split_once('/') {
1173 let feature_exists =
1181 features.contains_key(package) || !dep_edge_suppresses(features, package);
1182 if is_optional_dependency(table, package) && feature_exists {
1183 queue.push(package.to_owned());
1184 }
1185 } else {
1186 queue.push(implied.to_owned());
1187 }
1188 }
1189 }
1190 }
1191 enabled
1192}
1193
1194#[must_use]
1202pub fn flake_pair_withheld(
1203 flake_recorded: bool,
1204 flake_nix_present: bool,
1205 flake_lock_present: bool,
1206) -> Option<String> {
1207 if flake_recorded {
1208 return None;
1209 }
1210 let present: Vec<&str> = [
1211 ("flake.nix", flake_nix_present),
1212 ("flake.lock", flake_lock_present),
1213 ]
1214 .into_iter()
1215 .filter_map(|(name, present)| present.then_some(name))
1216 .collect();
1217 if present.is_empty() {
1218 return None;
1219 }
1220 Some(format!(
1221 "the target already carries {}; its flake pair stays its own",
1222 present.join(" and ")
1223 ))
1224}
1225
1226#[must_use]
1236pub fn nix_withholding(
1237 nix: bool,
1238 evidence: &TargetEvidence,
1239) -> Option<(&'static [&'static str], String)> {
1240 if !nix {
1241 return None;
1242 }
1243 if let Some(reason) = nix_unsupported_shape(&evidence.crate_shape) {
1244 return Some((&NIX_DESTINATIONS[..], reason));
1245 }
1246 flake_pair_withheld(
1247 evidence.flake_recorded,
1248 evidence.flake_nix_present,
1249 evidence.flake_lock_present,
1250 )
1251 .map(|reason| (&NIX_WITHHOLDABLE[..], reason))
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256 use super::{
1257 AGENTS_DESTINATION, BLOCK_BEGIN, BLOCK_DESTINATIONS, BLOCK_END, Candidate, Collision,
1258 CrateShape, GLOSSARY_DESTINATION, HOOK_TYPES_LINE, HOOKS_BEGIN, HOOKS_DESTINATION,
1259 HOOKS_END, Placement, Projection, ProjectionInput, TargetEvidence, extract_block,
1260 select_pair,
1261 };
1262 use crate::landing::{Params, Style};
1263
1264 fn supported_shape() -> CrateShape {
1266 CrateShape {
1267 cargo_toml: Some("[package]\nname = \"widget\"\nversion = \"0.1.0\"\n".to_owned()),
1268 cargo_lock: true,
1269 main_rs: true,
1270 }
1271 }
1272
1273 fn input(evidence: TargetEvidence) -> ProjectionInput {
1274 let mut params = Params::for_test("acme/widget", Some(Style::Trunk));
1275 params.set_nix_for_test(true);
1276 ProjectionInput { params, evidence }
1277 }
1278
1279 fn compute(evidence: TargetEvidence) -> Projection {
1280 Projection::compute(&input(evidence)).expect("the embedded pair projects")
1281 }
1282
1283 fn candidate<'a>(projection: &'a Projection, destination: &str) -> &'a Candidate {
1284 projection
1285 .candidates
1286 .iter()
1287 .find(|candidate| candidate.destination == destination)
1288 .expect("the destination projects")
1289 }
1290
1291 fn outside(bytes: &[u8], begin: &str, end: &str) -> (Vec<u8>, Vec<u8>) {
1293 let text = String::from_utf8_lossy(bytes);
1294 let start = text.find(begin).expect("the begin marker is present");
1295 let stop = text[start..].find(end).expect("the end marker is present") + start + end.len();
1296 (bytes[..start].to_vec(), bytes[stop..].to_vec())
1297 }
1298
1299 #[test]
1300 fn equal_projection_inputs_yield_byte_identical_projections() {
1301 let mut documents = std::collections::BTreeMap::new();
1302 documents.insert(
1303 AGENTS_DESTINATION.to_owned(),
1304 b"# Widget\n\nOwn rules.\n".to_vec(),
1305 );
1306 let evidence = TargetEvidence {
1307 documents,
1308 crate_shape: supported_shape(),
1309 ..TargetEvidence::default()
1310 };
1311 let first = input(evidence.clone());
1312 let second = input(evidence);
1313 assert_eq!(first, second, "the inputs are values and compare equal");
1314 let a = Projection::compute(&first).expect("the pair projects");
1315 let b = Projection::compute(&second).expect("the pair projects");
1316 assert_eq!(a.candidates.len(), b.candidates.len());
1317 for (x, y) in a.candidates.iter().zip(&b.candidates) {
1318 assert_eq!(x.destination, y.destination);
1319 assert_eq!(x.kind, y.kind);
1320 assert_eq!(x.placement, y.placement);
1321 assert_eq!(x.bytes, y.bytes, "{}", x.destination);
1322 assert_eq!(x.region, y.region, "{}", x.destination);
1323 assert_eq!(x.sources, y.sources, "{}", x.destination);
1324 }
1325 assert_eq!(a, b);
1326 let destinations: Vec<&str> = a
1327 .candidates
1328 .iter()
1329 .map(|candidate| candidate.destination.as_str())
1330 .collect();
1331 let mut sorted = destinations.clone();
1332 sorted.sort_unstable();
1333 assert_eq!(destinations, sorted, "candidates sort by destination");
1334 assert!(a.omissions.is_empty(), "{:?}", a.omissions);
1335 assert!(a.collisions.is_empty(), "{:?}", a.collisions);
1336 }
1337
1338 #[test]
1344 fn the_projection_performs_no_filesystem_git_environment_clock_registry_or_network_read() {
1345 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/projection.rs");
1346 let text = std::fs::read_to_string(&path).expect("the source reads");
1347 let production = text.split("#[cfg(test)]").next().unwrap_or("");
1348 let needles = [
1349 "std::fs",
1350 "std::env",
1351 "std::process",
1352 "std::time",
1353 "SystemTime",
1354 "Instant",
1355 "std::net",
1356 "Command::new",
1357 "registry::",
1358 "curl",
1359 "reqwest",
1360 "ReleaseSource",
1361 "ReleaseManifest",
1362 "blob(",
1363 ];
1364 let mut hits = Vec::new();
1365 for (index, line) in production.lines().enumerate() {
1366 if line.trim_start().starts_with("//") {
1367 continue;
1368 }
1369 for needle in needles {
1370 if line.contains(needle) {
1371 hits.push(format!("src/projection.rs:{}: {needle}", index + 1));
1372 }
1373 }
1374 }
1375 assert!(
1376 hits.is_empty(),
1377 "the projection reads beyond its inputs: {hits:?}"
1378 );
1379 }
1380
1381 #[test]
1382 #[allow(
1383 clippy::too_many_lines,
1384 reason = "one test walks the three marked destinations and the three unmarked shapes"
1385 )]
1386 fn marked_region_projection_preserves_every_target_byte_outside_the_markers() {
1387 let agents_before = "# Widget\n\nOperator prose above.\n\n";
1388 let agents_after = "\n\n## Our rules\n\nOperator prose below. \n";
1389 let glossary_before = "# Glossary\n\n- `spike` is a throwaway branch.\n\n";
1390 let glossary_after = "\n\n## More terms\n\n- `own` is ours.";
1391 let hooks_before = "default_install_hook_types: [pre-commit]\n\nrepos:\n";
1392 let hooks_after =
1393 "\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1394 let stale = |begin: &str, end: &str| format!("{begin}\nstale block\n{end}");
1395 let mut documents = std::collections::BTreeMap::new();
1396 documents.insert(
1397 AGENTS_DESTINATION.to_owned(),
1398 format!(
1399 "{agents_before}{}{agents_after}",
1400 stale(BLOCK_BEGIN, BLOCK_END)
1401 )
1402 .into_bytes(),
1403 );
1404 documents.insert(
1405 GLOSSARY_DESTINATION.to_owned(),
1406 format!(
1407 "{glossary_before}{}{glossary_after}",
1408 stale(BLOCK_BEGIN, BLOCK_END)
1409 )
1410 .into_bytes(),
1411 );
1412 documents.insert(
1413 HOOKS_DESTINATION.to_owned(),
1414 format!(
1415 "{hooks_before}{}{hooks_after}",
1416 stale(HOOKS_BEGIN, HOOKS_END)
1417 )
1418 .into_bytes(),
1419 );
1420 let projection = compute(TargetEvidence {
1421 documents: documents.clone(),
1422 crate_shape: supported_shape(),
1423 ..TargetEvidence::default()
1424 });
1425 assert!(
1426 projection.collisions.is_empty(),
1427 "{:?}",
1428 projection.collisions
1429 );
1430 for (destination, before, after) in [
1431 (AGENTS_DESTINATION, agents_before, agents_after),
1432 (GLOSSARY_DESTINATION, glossary_before, glossary_after),
1433 (HOOKS_DESTINATION, hooks_before, hooks_after),
1434 ] {
1435 let candidate = candidate(&projection, destination);
1436 let Placement::Region { begin, end } = candidate.placement else {
1437 panic!("{destination} is a region");
1438 };
1439 let region = candidate
1440 .region
1441 .as_deref()
1442 .expect("a region carries its block");
1443 let (head, tail) = outside(&candidate.bytes, begin, end);
1444 assert_eq!(
1445 head,
1446 before.as_bytes(),
1447 "{destination}: bytes before the markers"
1448 );
1449 assert_eq!(
1450 tail,
1451 after.as_bytes(),
1452 "{destination}: bytes after the markers"
1453 );
1454 let inside = &candidate.bytes[head.len()..candidate.bytes.len() - tail.len()];
1455 assert_eq!(
1456 inside, region,
1457 "{destination}: the region is the rendered block"
1458 );
1459 let (existing_head, existing_tail) = outside(&documents[destination], begin, end);
1460 assert_eq!(head, existing_head);
1461 assert_eq!(tail, existing_tail);
1462 }
1463
1464 let own_hooks =
1468 "repos:\n - repo: https://example.com/own\n rev: v1\n hooks:\n - id: own\n";
1469 let own_agents = "# Widget\n\nOwn rules.";
1470 let mut documents = std::collections::BTreeMap::new();
1471 documents.insert(HOOKS_DESTINATION.to_owned(), own_hooks.as_bytes().to_vec());
1472 documents.insert(
1473 AGENTS_DESTINATION.to_owned(),
1474 own_agents.as_bytes().to_vec(),
1475 );
1476 let projection = compute(TargetEvidence {
1477 documents,
1478 crate_shape: supported_shape(),
1479 ..TargetEvidence::default()
1480 });
1481 assert!(
1482 projection.collisions.is_empty(),
1483 "{:?}",
1484 projection.collisions
1485 );
1486 let hooks = candidate(&projection, HOOKS_DESTINATION);
1487 let hooks_text = String::from_utf8_lossy(&hooks.bytes);
1488 let region = String::from_utf8_lossy(hooks.region.as_deref().expect("a region"));
1489 assert!(
1490 hooks_text.starts_with(&format!(
1491 "repos:\n{region}\n - repo: https://example.com/own"
1492 )),
1493 "{hooks_text}"
1494 );
1495 assert!(!hooks_text.contains(HOOK_TYPES_LINE));
1496 let agents = candidate(&projection, AGENTS_DESTINATION);
1497 assert!(agents.bytes.starts_with(own_agents.as_bytes()));
1498 assert_eq!(
1499 extract_block(
1500 &String::from_utf8_lossy(&agents.bytes),
1501 BLOCK_BEGIN,
1502 BLOCK_END
1503 )
1504 .map(str::as_bytes),
1505 agents.region.as_deref()
1506 );
1507 let glossary = candidate(&projection, GLOSSARY_DESTINATION);
1508 let region = glossary.region.as_deref().expect("a region");
1509 assert_eq!(
1510 glossary.bytes,
1511 [region, b"\n"].concat(),
1512 "an absent file is fresh"
1513 );
1514 }
1515
1516 #[test]
1522 fn a_hook_document_that_is_not_utf8_collides_instead_of_being_rewritten() {
1523 let mut documents = std::collections::BTreeMap::new();
1524 let mut invalid = b"repos:\n# own \xff above\n".to_vec();
1525 invalid.extend_from_slice(format!("{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n").as_bytes());
1526 invalid.extend_from_slice(b" - repo: local \xff below\n");
1527 documents.insert(HOOKS_DESTINATION.to_owned(), invalid);
1528 let mut agents = b"# Widget r\xe9sum\xe9\n\n".to_vec();
1529 agents.extend_from_slice(format!("{BLOCK_BEGIN}\nstale\n{BLOCK_END}\n\n").as_bytes());
1530 agents.extend_from_slice(b"r\xe9sum\xe9\n");
1531 documents.insert(AGENTS_DESTINATION.to_owned(), agents.clone());
1532 let projection = compute(TargetEvidence {
1533 documents,
1534 crate_shape: supported_shape(),
1535 ..TargetEvidence::default()
1536 });
1537 let collided: Vec<&str> = projection
1538 .collisions
1539 .iter()
1540 .map(|c| c.destination.as_str())
1541 .collect();
1542 assert_eq!(collided, [HOOKS_DESTINATION]);
1543 assert!(
1544 projection.collisions[0].reason.contains("not UTF-8"),
1545 "{}",
1546 projection.collisions[0].reason
1547 );
1548 assert!(
1549 !projection
1550 .candidates
1551 .iter()
1552 .any(|c| c.destination == HOOKS_DESTINATION),
1553 "a colliding destination projects no candidate"
1554 );
1555 let agents = candidate(&projection, AGENTS_DESTINATION);
1556 assert!(
1557 agents.bytes.starts_with(b"# Widget r\xe9sum\xe9\n\n"),
1558 "{:?}",
1559 agents.bytes
1560 );
1561 assert!(
1562 agents.bytes.ends_with(b"\n\nr\xe9sum\xe9\n"),
1563 "{:?}",
1564 agents.bytes
1565 );
1566 assert!(
1567 !agents.bytes.contains(&0xEF),
1568 "a replacement character landed"
1569 );
1570
1571 let mut documents = std::collections::BTreeMap::new();
1572 let valid =
1573 format!("repos:\n# own above\n{HOOKS_BEGIN}\nstale\n{HOOKS_END}\n - repo: local\n");
1574 documents.insert(HOOKS_DESTINATION.to_owned(), valid.into_bytes());
1575 let projection = compute(TargetEvidence {
1576 documents,
1577 crate_shape: supported_shape(),
1578 ..TargetEvidence::default()
1579 });
1580 assert!(
1581 projection.collisions.is_empty(),
1582 "{:?}",
1583 projection.collisions
1584 );
1585 let hooks = candidate(&projection, HOOKS_DESTINATION);
1586 let text = String::from_utf8(hooks.bytes.clone()).expect("a valid document stays text");
1587 assert!(text.starts_with("repos:\n# own above\n"), "{text}");
1588 assert!(text.ends_with("\n - repo: local\n"), "{text}");
1589 assert!(!text.contains("stale"), "the region is replaced: {text}");
1590 }
1591
1592 #[test]
1596 fn a_whole_file_colliding_with_a_marked_region_names_both_source_paths() {
1597 let files: Vec<(String, &[u8])> = vec![
1598 ("snippets/_shared/github/SECURITY.md".to_owned(), b"policy"),
1599 ("snippets/rust/github/AGENTS.md".to_owned(), b"whole"),
1600 ];
1601 let err = Projection::compute_over(&files, &input(TargetEvidence::default()))
1602 .expect_err("a whole file at a block destination refuses");
1603 let text = err.to_string();
1604 assert!(text.contains("snippets/rust/github/AGENTS.md"), "{text}");
1605 assert!(text.contains(super::AGENTS_BLOCK), "{text}");
1606 assert!(text.contains(super::AGENTS_LINE_WORKTREE), "{text}");
1607 assert!(text.contains("payload is defective"), "{text}");
1608 }
1609
1610 #[test]
1611 fn duplicate_whole_file_destinations_and_overlapping_marked_regions_refuse_with_the_conflicting_source_names()
1612 {
1613 let files: Vec<(String, &[u8])> = vec![
1614 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
1615 ("snippets/rust/github/SECURITY.md".to_owned(), b"pair"),
1616 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
1617 ];
1618 let err = select_pair(&files, "rust", "github").expect_err("a doubled destination refuses");
1619 let text = err.to_string();
1620 assert!(
1621 text.contains("snippets/_shared/github/SECURITY.md"),
1622 "{text}"
1623 );
1624 assert!(text.contains("snippets/rust/github/SECURITY.md"), "{text}");
1625 assert!(text.contains("payload is defective"), "{text}");
1626
1627 let clean: Vec<(String, &[u8])> = vec![
1628 ("snippets/_shared/github/SECURITY.md".to_owned(), b"shared"),
1629 ("snippets/rust/github/release-plz.toml".to_owned(), b"seed"),
1630 ];
1631 let selected = select_pair(&clean, "rust", "github").expect("a clean list selects");
1632 let destinations: Vec<&str> = selected.iter().map(|s| s.destination.as_str()).collect();
1633 assert_eq!(destinations, ["SECURITY.md", "release-plz.toml"]);
1634 assert_eq!(selected[0].source, "snippets/_shared/github/SECURITY.md");
1635 let err = select_pair(&clean, "_shared", "github").expect_err("the shared zone is no tech");
1636 assert!(!err.to_string().contains("bindings are: _shared"), "{err}");
1637 let err = select_pair(&clean, "rust", "gitlab").expect_err("an unshipped pair refuses");
1638 assert!(err.to_string().contains("rust, github"), "{err}");
1639
1640 let doubled = format!("{BLOCK_BEGIN}\na\n{BLOCK_END}\n{BLOCK_BEGIN}\nb\n{BLOCK_END}\n");
1641 let unmatched = format!("repos:\n{HOOKS_BEGIN}\n - repo: local\n");
1642 let misordered = format!("# G\n{BLOCK_END}\n{BLOCK_BEGIN}\n");
1643 let mut documents = std::collections::BTreeMap::new();
1644 documents.insert(AGENTS_DESTINATION.to_owned(), doubled.into_bytes());
1645 documents.insert(HOOKS_DESTINATION.to_owned(), unmatched.into_bytes());
1646 documents.insert(GLOSSARY_DESTINATION.to_owned(), misordered.into_bytes());
1647 let projection = compute(TargetEvidence {
1648 documents,
1649 crate_shape: supported_shape(),
1650 ..TargetEvidence::default()
1651 });
1652 let mut collided: Vec<&str> = projection
1653 .collisions
1654 .iter()
1655 .map(|Collision { destination, .. }| destination.as_str())
1656 .collect();
1657 collided.sort_unstable();
1658 let mut expected = BLOCK_DESTINATIONS.to_vec();
1659 expected.sort_unstable();
1660 assert_eq!(collided, expected);
1661 for collision in &projection.collisions {
1662 assert!(
1663 collision.reason.contains(&collision.destination),
1664 "{collision:?}"
1665 );
1666 assert!(
1667 !projection
1668 .candidates
1669 .iter()
1670 .any(|candidate| candidate.destination == collision.destination),
1671 "{} collided and still projects",
1672 collision.destination
1673 );
1674 }
1675 let agents = projection
1676 .collisions
1677 .iter()
1678 .find(|c| c.destination == AGENTS_DESTINATION)
1679 .expect("the doubled document collides");
1680 assert!(agents.reason.contains("more than one"), "{}", agents.reason);
1681 let hooks = projection
1682 .collisions
1683 .iter()
1684 .find(|c| c.destination == HOOKS_DESTINATION)
1685 .expect("the unmatched document collides");
1686 assert!(hooks.reason.contains("unmatched"), "{}", hooks.reason);
1687 }
1688}