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 repo_placeholder: bool,
170 pub nix_withheld: Option<(Vec<String>, String)>,
172}
173
174#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
176pub struct Flags {
177 pub tech: Option<String>,
179 pub forge: Option<String>,
181 pub repo: Option<String>,
183 pub workflow: Option<String>,
185 pub style: Option<String>,
187 pub nix: Option<bool>,
189}
190
191pub struct Request<'a> {
193 pub target: &'a Utf8Path,
195 pub flags: &'a Flags,
197 pub decisions: &'a BTreeMap<String, String>,
199 pub observe_forge: bool,
201 pub clock: &'a str,
203 pub source: &'a dyn ReleaseSource,
205 pub extra_paths: &'a [String],
208}
209
210pub fn observe(request: &Request<'_>) -> Result<Observation, RkError> {
220 let target = request.target;
221 if !target.is_dir() {
222 return Err(RkError::missing(
223 Diagnostic::new(
224 Reason::TargetNotFound,
225 format!("target {target} is not a directory"),
226 )
227 .expected("an existing repository to plan for"),
228 ));
229 }
230 let clock = request.clock;
231 let mut ledger = Ledger::new();
232 let mut refs = Refs::default();
233 let record = read_record(target, clock, &mut ledger, &mut refs)?;
234 let config = read_config(target, clock, &mut ledger, &mut refs)?;
235 let facts = crate::assess::gather_facts(target)?;
236 refs.repository.push(ledger.observe(
237 "repository",
238 EvidenceKind::Repository,
239 "rk",
240 clock,
241 None,
242 "marker scan, payload destinations, git tag --list, git for-each-ref, version file",
243 ));
244 let files = read_destinations(
245 target,
246 &record,
247 request.extra_paths,
248 clock,
249 &mut ledger,
250 &mut refs,
251 )?;
252 let hooks_defect = landing::hooks_file_defect(request.source, target)?;
253 refs.host.push(ledger.observe(
254 "host",
255 EvidenceKind::Host,
256 "rk",
257 clock,
258 None,
259 "the engine's own version",
260 ));
261 let (pin, unwired_managers) = read_pin(target, clock, &mut ledger, &mut refs);
262 let forge = if request.observe_forge {
263 let trunk = crate::config::trunk_of(target.as_std_path())?;
264 let remote_tip = remote_tip(target, &trunk)?;
265 refs.forge.push(ledger.observe(
266 "forge:trunk",
267 EvidenceKind::Forge,
268 "git",
269 clock,
270 None,
271 format!("git ls-remote --heads origin {trunk}"),
272 ));
273 ForgeRead::Observed {
274 trunk,
275 remote_tip,
276 version: None,
277 }
278 } else {
279 ForgeRead::NotObserved {
280 reason: "the forge read was not requested; --observe forge opts in".into(),
281 }
282 };
283 Ok(Observation {
284 target: target.to_string(),
285 git: facts.git,
286 tech: facts.tech.map(str::to_owned),
287 forge_name: facts.forge.map(str::to_owned),
288 repo: facts.repo.clone(),
289 facts: RepositoryFacts {
290 release_markers: facts.release_markers,
291 collisions: facts.collisions,
292 tags: facts.tags,
293 long_lived_branches: facts.long_lived_branches,
294 },
295 record,
296 config,
297 files,
298 hooks_defect,
299 pin,
300 unwired_managers,
301 generator: None,
302 forge,
303 ledger,
304 refs,
305 })
306}
307
308pub fn observe_host_tools(
315 observation: &mut Observation,
316 tech: Option<&str>,
317 forge: Option<&str>,
318 clock: &str,
319) {
320 if let Some((name, _)) = tech.and_then(super::compatibility::generator_for) {
321 let host = generator_version(name);
322 observation.refs.host.push(observation.ledger.observe(
323 format!("host:{name}"),
324 EvidenceKind::Host,
325 name,
326 clock,
327 None,
328 format!("{} --version", generator_bin(name)),
329 ));
330 observation.generator = Some(GeneratorRead {
331 name: name.to_owned(),
332 host,
333 });
334 }
335 if let (Some("gitlab"), ForgeRead::Observed { version, .. }) = (forge, &mut observation.forge) {
336 *version = gitlab_version();
337 observation.refs.forge.push(observation.ledger.observe(
338 "forge:version",
339 EvidenceKind::Forge,
340 "glab",
341 clock,
342 None,
343 "glab api version",
344 ));
345 }
346}
347
348fn generator_bin(name: &str) -> String {
351 match name {
352 "cargo-dist" => std::env::var("RK_DIST_BIN").unwrap_or_else(|_| "dist".to_owned()),
353 other => other.to_owned(),
354 }
355}
356
357fn generator_version(name: &str) -> Option<String> {
360 let out = Command::new(generator_bin(name))
361 .arg("--version")
362 .output()
363 .ok()?;
364 if !out.status.success() {
365 return None;
366 }
367 let text = String::from_utf8_lossy(&out.stdout);
368 text.split_whitespace()
369 .find(|word| crate::release::declared::version_key(word).is_some())
370 .map(|word| word.trim_start_matches('v').to_owned())
371}
372
373fn gitlab_version() -> Option<String> {
376 let out = Command::new(crate::probes::forge_bin(crate::detect::Forge::Gitlab))
377 .args(["api", "version"])
378 .output()
379 .ok()?;
380 if !out.status.success() {
381 return None;
382 }
383 let body: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
384 body["version"].as_str().map(str::to_owned)
385}
386
387fn read_record(
389 target: &Utf8Path,
390 clock: &str,
391 ledger: &mut Ledger,
392 refs: &mut Refs,
393) -> Result<RecordRead, RkError> {
394 let bytes = read_optional(&target.join(manifest::MANIFEST_PATH))?;
395 let record = match (&bytes, manifest::load(target)) {
396 (None, _) => RecordRead::Absent,
397 (Some(bytes), Ok(Some(manifest))) => RecordRead::Present {
398 manifest: Box::new(manifest),
399 bytes: bytes.clone(),
400 },
401 (Some(_), Ok(None)) => RecordRead::Invalid {
402 reason: "the record vanished between two reads".into(),
403 },
404 (Some(_), Err(error)) => RecordRead::Invalid {
405 reason: error.to_string(),
406 },
407 };
408 refs.record = Some(ledger.observe(
409 "record",
410 EvidenceKind::Record,
411 "rk",
412 clock,
413 bytes.as_deref().map(Digest::of),
414 format!("read {}", manifest::MANIFEST_PATH),
415 ));
416 Ok(record)
417}
418
419fn read_config(
421 target: &Utf8Path,
422 clock: &str,
423 ledger: &mut Ledger,
424 refs: &mut Refs,
425) -> Result<ConfigRead, RkError> {
426 let bytes = read_optional(&target.join(crate::config::CONFIG_PATH))?;
427 let config = match (&bytes, crate::config::load(target.as_std_path())) {
428 (None, _) => ConfigRead::Absent,
429 (Some(bytes), Ok(Some(config))) => ConfigRead::Present {
430 config: Box::new(config),
431 bytes: bytes.clone(),
432 },
433 (Some(bytes), Ok(None)) => ConfigRead::Invalid {
434 reason: "the configuration vanished between two reads".into(),
435 bytes: bytes.clone(),
436 },
437 (Some(bytes), Err(error)) => ConfigRead::Invalid {
438 reason: error.to_string(),
439 bytes: bytes.clone(),
440 },
441 };
442 refs.configuration = Some(ledger.observe(
443 "configuration",
444 EvidenceKind::Configuration,
445 "rk",
446 clock,
447 bytes.as_deref().map(Digest::of),
448 format!("read {}", crate::config::CONFIG_PATH),
449 ));
450 Ok(config)
451}
452
453fn read_destinations(
456 target: &Utf8Path,
457 record: &RecordRead,
458 extra_paths: &[String],
459 clock: &str,
460 ledger: &mut Ledger,
461 refs: &mut Refs,
462) -> Result<BTreeMap<String, Vec<u8>>, RkError> {
463 let mut paths: Vec<String> = landing::destinations().map(str::to_owned).collect();
464 if let RecordRead::Present { manifest, .. } = record {
465 paths.extend(manifest.files.iter().map(|file| file.destination.clone()));
466 }
467 paths.extend(extra_paths.iter().cloned());
468 paths.sort();
469 paths.dedup();
470 let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
471 for path in paths {
472 let bytes = landing::read_recorded(target, &path)?;
473 let id = ledger.observe(
474 format!("destination:{path}"),
475 EvidenceKind::Destination,
476 "rk",
477 clock,
478 bytes.as_deref().map(Digest::of),
479 if landing::block_markers(&path).is_some() {
480 "read the marked block"
481 } else {
482 "read the file"
483 },
484 );
485 refs.destinations.insert(path.clone(), id);
486 if let Some(bytes) = bytes {
487 files.insert(path, bytes);
488 }
489 }
490 Ok(files)
491}
492
493fn read_pin(
497 target: &Utf8Path,
498 clock: &str,
499 ledger: &mut Ledger,
500 refs: &mut Refs,
501) -> (Option<PinState>, Vec<String>) {
502 let Ok(observed) = crate::self_depend::observe(target) else {
503 return (None, Vec::new());
504 };
505 let unwired: Vec<String> = observed
510 .managers
511 .iter()
512 .filter(|entry| {
513 entry.present == crate::self_depend::Presence::Present
514 && matches!(entry.pin, "unpinned" | "absent")
515 })
516 .map(|entry| entry.manager.as_str().to_owned())
517 .collect();
518 let pin = observed.wired.and_then(|manager| {
519 let entry = observed.entry(manager)?;
520 Some(PinState {
521 manager: manager.as_str().to_owned(),
522 file: entry.file.clone()?,
523 version: entry.version.clone()?,
524 })
525 });
526 if let Some(pin) = &pin {
527 refs.pin = Some(ledger.observe(
528 "pin",
529 EvidenceKind::Pin,
530 "rk self-depend",
531 clock,
532 files_digest(target, &pin.file),
533 format!("read {}", pin.file),
534 ));
535 }
536 (pin, unwired)
537}
538
539pub fn resolve(request: &Request<'_>, observation: &Observation) -> Result<Resolution, RkError> {
552 let flags = request.flags;
553 let workflow_flag = flags
554 .workflow
555 .clone()
556 .or_else(|| request.decisions.get("workflow-mode").cloned());
557 let style_flag = flags
558 .style
559 .clone()
560 .or_else(|| request.decisions.get("release-style").cloned());
561 let inputs = landing::Inputs {
562 tech: flags.tech.as_deref(),
563 forge: flags.forge.as_deref(),
564 repo: flags.repo.as_deref(),
565 workflow: workflow_flag.as_deref().map(Workflow::parse).transpose()?,
566 style: style_flag.as_deref().map(Style::parse).transpose()?,
567 nix: flags.nix,
568 };
569 let config: Option<&Config> = match &observation.config {
570 ConfigRead::Present { config, .. } => Some(config),
571 ConfigRead::Absent | ConfigRead::Invalid { .. } => None,
572 };
573 let record: Option<&Manifest> = match &observation.record {
574 RecordRead::Present { manifest, .. } => Some(manifest),
575 RecordRead::Absent | RecordRead::Invalid { .. } => None,
576 };
577 let sources = sources(
578 &inputs,
579 flags,
580 request.decisions,
581 config,
582 record,
583 observation,
584 );
585 let resolved = Params::resolve(
586 request.source,
587 request.target,
588 &inputs,
589 config,
590 record,
591 landing::Purpose::Preview,
592 );
593 let (params, unresolved) = match resolved {
594 Ok(params) => (Some(params), None),
595 Err(error) => (None, Some(error.to_string())),
596 };
597 let repo_placeholder = params
602 .as_ref()
603 .is_some_and(|params| params.repo() == landing::REPO_PLACEHOLDER);
604 let nix_withheld = match ¶ms {
605 Some(params) if params.nix() => landing::nix_withholding(request.target, record)?
606 .map(|(set, reason)| (set.iter().map(|path| (*path).to_owned()).collect(), reason)),
607 _ => None,
608 };
609 Ok(Resolution {
610 params,
611 sources,
612 unresolved,
613 repo_placeholder,
614 nix_withheld,
615 })
616}
617
618fn sources(
621 inputs: &landing::Inputs<'_>,
622 flags: &Flags,
623 decisions: &BTreeMap<String, String>,
624 config: Option<&Config>,
625 record: Option<&Manifest>,
626 observation: &Observation,
627) -> BTreeMap<String, String> {
628 let mut sources = BTreeMap::new();
629 let layer = |flag: bool, configured: bool, recorded: bool, detected: bool| {
630 if flag {
631 "flag"
632 } else if configured {
633 "configuration"
634 } else if recorded {
635 "record"
636 } else if detected {
637 "detected"
638 } else {
639 "default"
640 }
641 };
642 identity_sources(&mut sources, inputs, config, record, observation, layer);
643 let decided = |id: &str, flag: bool, configured: bool, recorded: bool| {
644 if flag {
645 "flag"
646 } else if decisions.contains_key(id) {
647 "decision"
648 } else if configured {
649 "configuration"
650 } else if recorded {
651 "record"
652 } else {
653 "default"
654 }
655 };
656 sources.insert(
657 "workflow".to_owned(),
658 decided(
659 "workflow-mode",
660 flags.workflow.is_some(),
661 config.is_some_and(|c| c.landing.workflow.is_some()),
662 record.is_some(),
663 )
664 .to_owned(),
665 );
666 sources.insert(
667 "style".to_owned(),
668 decided(
669 "release-style",
670 flags.style.is_some(),
671 config.is_some_and(|c| c.landing.style.is_some()),
672 record.is_some_and(|r| r.parameters.style.is_some()),
673 )
674 .to_owned(),
675 );
676 sources.insert(
677 "nix".to_owned(),
678 layer(
679 inputs.nix.is_some(),
680 config.is_some_and(|c| c.landing.nix.is_some()),
681 record.is_some(),
682 false,
683 )
684 .to_owned(),
685 );
686 for (key, configured) in [
687 ("trunk", config.is_some_and(|c| c.project.trunk.is_some())),
688 (
689 "line_prefix",
690 config.is_some_and(|c| c.setup.line_prefix.is_some()),
691 ),
692 (
693 "security_contact",
694 config.is_some_and(|c| c.security.contact.is_some()),
695 ),
696 (
697 "security_response",
698 config.is_some_and(|c| c.security.response.is_some()),
699 ),
700 ] {
701 sources.insert(
702 key.to_owned(),
703 layer(false, configured, record.is_some(), false).to_owned(),
704 );
705 }
706 sources
707}
708
709fn identity_sources(
712 sources: &mut BTreeMap<String, String>,
713 inputs: &landing::Inputs<'_>,
714 config: Option<&Config>,
715 record: Option<&Manifest>,
716 observation: &Observation,
717 layer: impl Fn(bool, bool, bool, bool) -> &'static str,
718) {
719 sources.insert(
720 "tech".to_owned(),
721 layer(
722 inputs.tech.is_some(),
723 config.is_some_and(|c| !c.project.tech.is_empty()),
724 record.is_some(),
725 observation.tech.is_some(),
726 )
727 .to_owned(),
728 );
729 sources.insert(
730 "forge".to_owned(),
731 layer(
732 inputs.forge.is_some(),
733 config.is_some_and(|c| !c.project.forge.is_empty()),
734 record.is_some(),
735 observation.forge_name.is_some(),
736 )
737 .to_owned(),
738 );
739 sources.insert(
740 "repo".to_owned(),
741 layer(
742 inputs.repo.is_some(),
743 config.is_some_and(|c| !c.project.repo.is_empty()),
744 record.is_some(),
745 observation.repo.is_some(),
746 )
747 .to_owned(),
748 );
749}
750
751fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>, RkError> {
753 match std::fs::read(path) {
754 Ok(bytes) => Ok(Some(bytes)),
755 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
756 Err(error) => Err(RkError::Io(error)),
757 }
758}
759
760fn files_digest(target: &Utf8Path, rel: &str) -> Option<Digest> {
762 std::fs::read(target.join(rel))
763 .ok()
764 .map(|bytes| Digest::of(&bytes))
765}
766
767fn remote_tip(target: &Utf8Path, trunk: &str) -> Result<Option<String>, RkError> {
773 let mut command = Command::new(crate::probes::git_bin());
774 for var in crate::maintenance::GIT_HOOK_VARS {
775 command.env_remove(var);
776 }
777 let out = command
778 .arg("-C")
779 .arg(target)
780 .args(["ls-remote", "--heads", "origin", trunk])
781 .output()
782 .map_err(|error| {
783 RkError::subprocess(
784 Diagnostic::new(
785 Reason::SubprocessSpawn,
786 format!("git could not be spawned: {error}"),
787 )
788 .expected("git on PATH, or RK_GIT_BIN naming it"),
789 )
790 })?;
791 if !out.status.success() {
792 return Err(RkError::subprocess(
793 Diagnostic::new(
794 Reason::ForgeTemporary,
795 format!(
796 "git ls-remote could not read origin: {}",
797 String::from_utf8_lossy(&out.stderr).trim()
798 ),
799 )
800 .expected("a reachable origin remote, or a plan without --observe forge"),
801 ));
802 }
803 Ok(String::from_utf8_lossy(&out.stdout)
804 .lines()
805 .find_map(|line| line.split_whitespace().next().map(str::to_owned)))
806}