1use std::collections::BTreeMap;
12use std::process::Command;
13
14use camino::Utf8Path;
15
16use crate::config::Config;
17use crate::diagnostic::{Diagnostic, Reason};
18use crate::digest::Digest;
19use crate::error::RkError;
20use crate::landing::manifest::{self, Manifest, Style, Workflow};
21use crate::landing::{self, Params};
22use crate::release::ReleaseSource;
23
24use super::PinState;
25use super::classify::RepositoryFacts;
26use super::evidence::{EvidenceKind, Ledger};
27
28#[derive(Debug)]
30pub enum RecordRead {
31 Absent,
33 Present {
35 manifest: Box<Manifest>,
37 bytes: Vec<u8>,
39 },
40 Invalid {
42 reason: String,
44 },
45}
46
47#[derive(Debug)]
49pub enum ConfigRead {
50 Absent,
52 Present {
54 config: Box<Config>,
56 bytes: Vec<u8>,
58 },
59 Invalid {
61 reason: String,
63 bytes: Vec<u8>,
65 },
66}
67
68#[derive(Debug)]
70pub enum ForgeRead {
71 NotObserved {
73 reason: String,
75 },
76 Observed {
78 trunk: String,
80 remote_tip: Option<String>,
82 version: Option<String>,
85 },
86}
87
88#[derive(Debug, Clone)]
91pub struct GeneratorRead {
92 pub name: String,
94 pub host: Option<String>,
96}
97
98#[derive(Debug, Default)]
100pub struct Refs {
101 pub repository: Vec<String>,
103 pub record: Option<String>,
105 pub configuration: Option<String>,
107 pub destinations: BTreeMap<String, String>,
109 pub host: Vec<String>,
111 pub pin: Option<String>,
113 pub forge: Vec<String>,
115}
116
117#[derive(Debug)]
119pub struct Observation {
120 pub target: String,
122 pub git: bool,
124 pub tech: Option<String>,
126 pub forge_name: Option<String>,
128 pub repo: Option<String>,
130 pub facts: RepositoryFacts,
132 pub record: RecordRead,
134 pub config: ConfigRead,
136 pub files: BTreeMap<String, Vec<u8>>,
139 pub hooks_defect: Option<String>,
141 pub pin: Option<PinState>,
143 pub unwired_managers: Vec<String>,
145 pub generator: Option<GeneratorRead>,
148 pub forge: ForgeRead,
150 pub ledger: Ledger,
152 pub refs: Refs,
154}
155
156#[derive(Debug)]
159pub struct Resolution {
160 pub params: Option<Params>,
162 pub sources: BTreeMap<String, String>,
164 pub unresolved: Option<String>,
166 pub nix_withheld: Option<(Vec<String>, String)>,
168}
169
170#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
172pub struct Flags {
173 pub tech: Option<String>,
175 pub forge: Option<String>,
177 pub repo: Option<String>,
179 pub workflow: Option<String>,
181 pub style: Option<String>,
183 pub nix: Option<bool>,
185}
186
187pub struct Request<'a> {
189 pub target: &'a Utf8Path,
191 pub flags: &'a Flags,
193 pub decisions: &'a BTreeMap<String, String>,
195 pub observe_forge: bool,
197 pub clock: &'a str,
199 pub source: &'a dyn ReleaseSource,
201 pub extra_paths: &'a [String],
204}
205
206pub fn observe(request: &Request<'_>) -> Result<Observation, RkError> {
216 let target = request.target;
217 if !target.is_dir() {
218 return Err(RkError::missing(
219 Diagnostic::new(
220 Reason::TargetNotFound,
221 format!("target {target} is not a directory"),
222 )
223 .expected("an existing repository to plan for"),
224 ));
225 }
226 let clock = request.clock;
227 let mut ledger = Ledger::new();
228 let mut refs = Refs::default();
229 let record = read_record(target, clock, &mut ledger, &mut refs)?;
230 let config = read_config(target, clock, &mut ledger, &mut refs)?;
231 let facts = crate::assess::gather_facts(target)?;
232 refs.repository.push(ledger.observe(
233 "repository",
234 EvidenceKind::Repository,
235 "rk",
236 clock,
237 None,
238 "marker scan, payload destinations, git tag --list, git for-each-ref, version file",
239 ));
240 let files = read_destinations(
241 target,
242 &record,
243 request.extra_paths,
244 clock,
245 &mut ledger,
246 &mut refs,
247 )?;
248 let hooks_defect = landing::hooks_file_defect(request.source, target)?;
249 refs.host.push(ledger.observe(
250 "host",
251 EvidenceKind::Host,
252 "rk",
253 clock,
254 None,
255 "the engine's own version",
256 ));
257 let (pin, unwired_managers) = read_pin(target, clock, &mut ledger, &mut refs);
258 let forge = if request.observe_forge {
259 let trunk = crate::config::trunk_of(target.as_std_path())?;
260 let remote_tip = remote_tip(target, &trunk)?;
261 refs.forge.push(ledger.observe(
262 "forge:trunk",
263 EvidenceKind::Forge,
264 "git",
265 clock,
266 None,
267 format!("git ls-remote --heads origin {trunk}"),
268 ));
269 ForgeRead::Observed {
270 trunk,
271 remote_tip,
272 version: None,
273 }
274 } else {
275 ForgeRead::NotObserved {
276 reason: "the forge read was not requested; --observe forge opts in".into(),
277 }
278 };
279 Ok(Observation {
280 target: target.to_string(),
281 git: facts.git,
282 tech: facts.tech.map(str::to_owned),
283 forge_name: facts.forge.map(str::to_owned),
284 repo: facts.repo.clone(),
285 facts: RepositoryFacts {
286 release_markers: facts.release_markers,
287 collisions: facts.collisions,
288 tags: facts.tags,
289 long_lived_branches: facts.long_lived_branches,
290 },
291 record,
292 config,
293 files,
294 hooks_defect,
295 pin,
296 unwired_managers,
297 generator: None,
298 forge,
299 ledger,
300 refs,
301 })
302}
303
304pub fn observe_host_tools(
311 observation: &mut Observation,
312 tech: Option<&str>,
313 forge: Option<&str>,
314 clock: &str,
315) {
316 if let Some((name, _)) = tech.and_then(super::compatibility::generator_for) {
317 let host = generator_version(name);
318 observation.refs.host.push(observation.ledger.observe(
319 format!("host:{name}"),
320 EvidenceKind::Host,
321 name,
322 clock,
323 None,
324 format!("{} --version", generator_bin(name)),
325 ));
326 observation.generator = Some(GeneratorRead {
327 name: name.to_owned(),
328 host,
329 });
330 }
331 if let (Some("gitlab"), ForgeRead::Observed { version, .. }) = (forge, &mut observation.forge) {
332 *version = gitlab_version();
333 observation.refs.forge.push(observation.ledger.observe(
334 "forge:version",
335 EvidenceKind::Forge,
336 "glab",
337 clock,
338 None,
339 "glab api version",
340 ));
341 }
342}
343
344fn generator_bin(name: &str) -> String {
347 match name {
348 "cargo-dist" => std::env::var("RK_DIST_BIN").unwrap_or_else(|_| "dist".to_owned()),
349 other => other.to_owned(),
350 }
351}
352
353fn generator_version(name: &str) -> Option<String> {
356 let out = Command::new(generator_bin(name))
357 .arg("--version")
358 .output()
359 .ok()?;
360 if !out.status.success() {
361 return None;
362 }
363 let text = String::from_utf8_lossy(&out.stdout);
364 text.split_whitespace()
365 .find(|word| crate::release::declared::version_key(word).is_some())
366 .map(|word| word.trim_start_matches('v').to_owned())
367}
368
369fn gitlab_version() -> Option<String> {
372 let out = Command::new(crate::probes::forge_bin(crate::detect::Forge::Gitlab))
373 .args(["api", "version"])
374 .output()
375 .ok()?;
376 if !out.status.success() {
377 return None;
378 }
379 let body: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
380 body["version"].as_str().map(str::to_owned)
381}
382
383fn read_record(
385 target: &Utf8Path,
386 clock: &str,
387 ledger: &mut Ledger,
388 refs: &mut Refs,
389) -> Result<RecordRead, RkError> {
390 let bytes = read_optional(&target.join(manifest::MANIFEST_PATH))?;
391 let record = match (&bytes, manifest::load(target)) {
392 (None, _) => RecordRead::Absent,
393 (Some(bytes), Ok(Some(manifest))) => RecordRead::Present {
394 manifest: Box::new(manifest),
395 bytes: bytes.clone(),
396 },
397 (Some(_), Ok(None)) => RecordRead::Invalid {
398 reason: "the record vanished between two reads".into(),
399 },
400 (Some(_), Err(error)) => RecordRead::Invalid {
401 reason: error.to_string(),
402 },
403 };
404 refs.record = Some(ledger.observe(
405 "record",
406 EvidenceKind::Record,
407 "rk",
408 clock,
409 bytes.as_deref().map(Digest::of),
410 format!("read {}", manifest::MANIFEST_PATH),
411 ));
412 Ok(record)
413}
414
415fn read_config(
417 target: &Utf8Path,
418 clock: &str,
419 ledger: &mut Ledger,
420 refs: &mut Refs,
421) -> Result<ConfigRead, RkError> {
422 let bytes = read_optional(&target.join(crate::config::CONFIG_PATH))?;
423 let config = match (&bytes, crate::config::load(target.as_std_path())) {
424 (None, _) => ConfigRead::Absent,
425 (Some(bytes), Ok(Some(config))) => ConfigRead::Present {
426 config: Box::new(config),
427 bytes: bytes.clone(),
428 },
429 (Some(bytes), Ok(None)) => ConfigRead::Invalid {
430 reason: "the configuration vanished between two reads".into(),
431 bytes: bytes.clone(),
432 },
433 (Some(bytes), Err(error)) => ConfigRead::Invalid {
434 reason: error.to_string(),
435 bytes: bytes.clone(),
436 },
437 };
438 refs.configuration = Some(ledger.observe(
439 "configuration",
440 EvidenceKind::Configuration,
441 "rk",
442 clock,
443 bytes.as_deref().map(Digest::of),
444 format!("read {}", crate::config::CONFIG_PATH),
445 ));
446 Ok(config)
447}
448
449fn read_destinations(
452 target: &Utf8Path,
453 record: &RecordRead,
454 extra_paths: &[String],
455 clock: &str,
456 ledger: &mut Ledger,
457 refs: &mut Refs,
458) -> Result<BTreeMap<String, Vec<u8>>, RkError> {
459 let mut paths: Vec<String> = landing::destinations().map(str::to_owned).collect();
460 if let RecordRead::Present { manifest, .. } = record {
461 paths.extend(manifest.files.iter().map(|file| file.destination.clone()));
462 }
463 paths.extend(extra_paths.iter().cloned());
464 paths.sort();
465 paths.dedup();
466 let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
467 for path in paths {
468 let bytes = landing::read_recorded(target, &path)?;
469 let id = ledger.observe(
470 format!("destination:{path}"),
471 EvidenceKind::Destination,
472 "rk",
473 clock,
474 bytes.as_deref().map(Digest::of),
475 if landing::block_markers(&path).is_some() {
476 "read the marked block"
477 } else {
478 "read the file"
479 },
480 );
481 refs.destinations.insert(path.clone(), id);
482 if let Some(bytes) = bytes {
483 files.insert(path, bytes);
484 }
485 }
486 Ok(files)
487}
488
489fn read_pin(
493 target: &Utf8Path,
494 clock: &str,
495 ledger: &mut Ledger,
496 refs: &mut Refs,
497) -> (Option<PinState>, Vec<String>) {
498 let Ok(observed) = crate::self_depend::observe(target) else {
499 return (None, Vec::new());
500 };
501 let unwired: Vec<String> = observed
502 .managers
503 .iter()
504 .filter(|entry| {
505 entry.present == crate::self_depend::Presence::Present && entry.pin == "unpinned"
506 })
507 .map(|entry| entry.manager.as_str().to_owned())
508 .collect();
509 let pin = observed.wired.and_then(|manager| {
510 let entry = observed.entry(manager)?;
511 Some(PinState {
512 manager: manager.as_str().to_owned(),
513 file: entry.file.clone()?,
514 version: entry.version.clone()?,
515 })
516 });
517 if let Some(pin) = &pin {
518 refs.pin = Some(ledger.observe(
519 "pin",
520 EvidenceKind::Pin,
521 "rk self-depend",
522 clock,
523 files_digest(target, &pin.file),
524 format!("read {}", pin.file),
525 ));
526 }
527 (pin, unwired)
528}
529
530pub fn resolve(request: &Request<'_>, observation: &Observation) -> Result<Resolution, RkError> {
543 let flags = request.flags;
544 let workflow_flag = flags
545 .workflow
546 .clone()
547 .or_else(|| request.decisions.get("workflow-mode").cloned());
548 let style_flag = flags
549 .style
550 .clone()
551 .or_else(|| request.decisions.get("release-style").cloned());
552 let inputs = landing::Inputs {
553 tech: flags.tech.as_deref(),
554 forge: flags.forge.as_deref(),
555 repo: flags.repo.as_deref(),
556 workflow: workflow_flag.as_deref().map(Workflow::parse).transpose()?,
557 style: style_flag.as_deref().map(Style::parse).transpose()?,
558 nix: flags.nix,
559 };
560 let config: Option<&Config> = match &observation.config {
561 ConfigRead::Present { config, .. } => Some(config),
562 ConfigRead::Absent | ConfigRead::Invalid { .. } => None,
563 };
564 let record: Option<&Manifest> = match &observation.record {
565 RecordRead::Present { manifest, .. } => Some(manifest),
566 RecordRead::Absent | RecordRead::Invalid { .. } => None,
567 };
568 let sources = sources(
569 &inputs,
570 flags,
571 request.decisions,
572 config,
573 record,
574 observation,
575 );
576 let resolved = Params::resolve(
577 request.source,
578 request.target,
579 &inputs,
580 config,
581 record,
582 landing::Purpose::Preview,
583 );
584 let (params, unresolved) = match resolved {
585 Ok(params) => (Some(params), None),
586 Err(error) => (None, Some(error.to_string())),
587 };
588 let nix_withheld = match ¶ms {
589 Some(params) if params.nix() => landing::nix_withholding(request.target, record)?
590 .map(|(set, reason)| (set.iter().map(|path| (*path).to_owned()).collect(), reason)),
591 _ => None,
592 };
593 Ok(Resolution {
594 params,
595 sources,
596 unresolved,
597 nix_withheld,
598 })
599}
600
601fn sources(
604 inputs: &landing::Inputs<'_>,
605 flags: &Flags,
606 decisions: &BTreeMap<String, String>,
607 config: Option<&Config>,
608 record: Option<&Manifest>,
609 observation: &Observation,
610) -> BTreeMap<String, String> {
611 let mut sources = BTreeMap::new();
612 let layer = |flag: bool, configured: bool, recorded: bool, detected: bool| {
613 if flag {
614 "flag"
615 } else if configured {
616 "configuration"
617 } else if recorded {
618 "record"
619 } else if detected {
620 "detected"
621 } else {
622 "default"
623 }
624 };
625 identity_sources(&mut sources, inputs, config, record, observation, layer);
626 let decided = |id: &str, flag: bool, configured: bool, recorded: bool| {
627 if flag {
628 "flag"
629 } else if decisions.contains_key(id) {
630 "decision"
631 } else if configured {
632 "configuration"
633 } else if recorded {
634 "record"
635 } else {
636 "default"
637 }
638 };
639 sources.insert(
640 "workflow".to_owned(),
641 decided(
642 "workflow-mode",
643 flags.workflow.is_some(),
644 config.is_some_and(|c| c.landing.workflow.is_some()),
645 record.is_some(),
646 )
647 .to_owned(),
648 );
649 sources.insert(
650 "style".to_owned(),
651 decided(
652 "release-style",
653 flags.style.is_some(),
654 config.is_some_and(|c| c.landing.style.is_some()),
655 record.is_some_and(|r| r.parameters.style.is_some()),
656 )
657 .to_owned(),
658 );
659 sources.insert(
660 "nix".to_owned(),
661 layer(
662 inputs.nix.is_some(),
663 config.is_some_and(|c| c.landing.nix.is_some()),
664 record.is_some(),
665 false,
666 )
667 .to_owned(),
668 );
669 for (key, configured) in [
670 ("trunk", config.is_some_and(|c| c.project.trunk.is_some())),
671 (
672 "line_prefix",
673 config.is_some_and(|c| c.setup.line_prefix.is_some()),
674 ),
675 (
676 "security_contact",
677 config.is_some_and(|c| c.security.contact.is_some()),
678 ),
679 (
680 "security_response",
681 config.is_some_and(|c| c.security.response.is_some()),
682 ),
683 ] {
684 sources.insert(
685 key.to_owned(),
686 layer(false, configured, record.is_some(), false).to_owned(),
687 );
688 }
689 sources
690}
691
692fn identity_sources(
695 sources: &mut BTreeMap<String, String>,
696 inputs: &landing::Inputs<'_>,
697 config: Option<&Config>,
698 record: Option<&Manifest>,
699 observation: &Observation,
700 layer: impl Fn(bool, bool, bool, bool) -> &'static str,
701) {
702 sources.insert(
703 "tech".to_owned(),
704 layer(
705 inputs.tech.is_some(),
706 config.is_some_and(|c| !c.project.tech.is_empty()),
707 record.is_some(),
708 observation.tech.is_some(),
709 )
710 .to_owned(),
711 );
712 sources.insert(
713 "forge".to_owned(),
714 layer(
715 inputs.forge.is_some(),
716 config.is_some_and(|c| !c.project.forge.is_empty()),
717 record.is_some(),
718 observation.forge_name.is_some(),
719 )
720 .to_owned(),
721 );
722 sources.insert(
723 "repo".to_owned(),
724 layer(
725 inputs.repo.is_some(),
726 config.is_some_and(|c| !c.project.repo.is_empty()),
727 record.is_some(),
728 observation.repo.is_some(),
729 )
730 .to_owned(),
731 );
732}
733
734fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>, RkError> {
736 match std::fs::read(path) {
737 Ok(bytes) => Ok(Some(bytes)),
738 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
739 Err(error) => Err(RkError::Io(error)),
740 }
741}
742
743fn files_digest(target: &Utf8Path, rel: &str) -> Option<Digest> {
745 std::fs::read(target.join(rel))
746 .ok()
747 .map(|bytes| Digest::of(&bytes))
748}
749
750fn remote_tip(target: &Utf8Path, trunk: &str) -> Result<Option<String>, RkError> {
756 let mut command = Command::new(crate::probes::git_bin());
757 for var in crate::maintenance::GIT_HOOK_VARS {
758 command.env_remove(var);
759 }
760 let out = command
761 .arg("-C")
762 .arg(target)
763 .args(["ls-remote", "--heads", "origin", trunk])
764 .output()
765 .map_err(|error| {
766 RkError::subprocess(
767 Diagnostic::new(
768 Reason::SubprocessSpawn,
769 format!("git could not be spawned: {error}"),
770 )
771 .expected("git on PATH, or RK_GIT_BIN naming it"),
772 )
773 })?;
774 if !out.status.success() {
775 return Err(RkError::subprocess(
776 Diagnostic::new(
777 Reason::ForgeTemporary,
778 format!(
779 "git ls-remote could not read origin: {}",
780 String::from_utf8_lossy(&out.stderr).trim()
781 ),
782 )
783 .expected("a reachable origin remote, or a plan without --observe forge"),
784 ));
785 }
786 Ok(String::from_utf8_lossy(&out.stdout)
787 .lines()
788 .find_map(|line| line.split_whitespace().next().map(str::to_owned)))
789}