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 },
83}
84
85#[derive(Debug, Default)]
87pub struct Refs {
88 pub repository: Vec<String>,
90 pub record: Option<String>,
92 pub configuration: Option<String>,
94 pub destinations: BTreeMap<String, String>,
96 pub host: Vec<String>,
98 pub pin: Option<String>,
100 pub forge: Vec<String>,
102}
103
104#[derive(Debug)]
106pub struct Observation {
107 pub target: String,
109 pub git: bool,
111 pub tech: Option<String>,
113 pub forge_name: Option<String>,
115 pub repo: Option<String>,
117 pub facts: RepositoryFacts,
119 pub record: RecordRead,
121 pub config: ConfigRead,
123 pub files: BTreeMap<String, Vec<u8>>,
126 pub hooks_defect: Option<String>,
128 pub pin: Option<PinState>,
130 pub forge: ForgeRead,
132 pub ledger: Ledger,
134 pub refs: Refs,
136}
137
138#[derive(Debug)]
141pub struct Resolution {
142 pub params: Option<Params>,
144 pub sources: BTreeMap<String, String>,
146 pub unresolved: Option<String>,
148 pub nix_withheld: Option<(Vec<String>, String)>,
150}
151
152#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
154pub struct Flags {
155 pub tech: Option<String>,
157 pub forge: Option<String>,
159 pub repo: Option<String>,
161 pub workflow: Option<String>,
163 pub style: Option<String>,
165 pub nix: Option<bool>,
167}
168
169pub struct Request<'a> {
171 pub target: &'a Utf8Path,
173 pub flags: &'a Flags,
175 pub decisions: &'a BTreeMap<String, String>,
177 pub observe_forge: bool,
179 pub clock: &'a str,
181 pub source: &'a dyn ReleaseSource,
183}
184
185pub fn observe(request: &Request<'_>) -> Result<Observation, RkError> {
195 let target = request.target;
196 if !target.is_dir() {
197 return Err(RkError::missing(
198 Diagnostic::new(
199 Reason::TargetNotFound,
200 format!("target {target} is not a directory"),
201 )
202 .expected("an existing repository to plan for"),
203 ));
204 }
205 let clock = request.clock;
206 let mut ledger = Ledger::new();
207 let mut refs = Refs::default();
208 let record = read_record(target, clock, &mut ledger, &mut refs)?;
209 let config = read_config(target, clock, &mut ledger, &mut refs)?;
210 let facts = crate::assess::gather_facts(target)?;
211 refs.repository.push(ledger.observe(
212 "repository",
213 EvidenceKind::Repository,
214 "rk",
215 clock,
216 None,
217 "marker scan, payload destinations, git tag --list, git for-each-ref, version file",
218 ));
219 let files = read_destinations(target, &record, clock, &mut ledger, &mut refs)?;
220 let hooks_defect = landing::hooks_file_defect(request.source, target)?;
221 refs.host.push(ledger.observe(
222 "host",
223 EvidenceKind::Host,
224 "rk",
225 clock,
226 None,
227 "the engine's own version",
228 ));
229 let pin = read_pin(target, clock, &mut ledger, &mut refs);
230 let forge = if request.observe_forge {
231 let trunk = crate::config::trunk_of(target.as_std_path())?;
232 let remote_tip = remote_tip(target, &trunk)?;
233 refs.forge.push(ledger.observe(
234 "forge:trunk",
235 EvidenceKind::Forge,
236 "git",
237 clock,
238 None,
239 format!("git ls-remote --heads origin {trunk}"),
240 ));
241 ForgeRead::Observed { trunk, remote_tip }
242 } else {
243 ForgeRead::NotObserved {
244 reason: "the forge read was not requested; --observe forge opts in".into(),
245 }
246 };
247 Ok(Observation {
248 target: target.to_string(),
249 git: facts.git,
250 tech: facts.tech.map(str::to_owned),
251 forge_name: facts.forge.map(str::to_owned),
252 repo: facts.repo.clone(),
253 facts: RepositoryFacts {
254 release_markers: facts.release_markers,
255 collisions: facts.collisions,
256 tags: facts.tags,
257 long_lived_branches: facts.long_lived_branches,
258 },
259 record,
260 config,
261 files,
262 hooks_defect,
263 pin,
264 forge,
265 ledger,
266 refs,
267 })
268}
269
270fn read_record(
272 target: &Utf8Path,
273 clock: &str,
274 ledger: &mut Ledger,
275 refs: &mut Refs,
276) -> Result<RecordRead, RkError> {
277 let bytes = read_optional(&target.join(manifest::MANIFEST_PATH))?;
278 let record = match (&bytes, manifest::load(target)) {
279 (None, _) => RecordRead::Absent,
280 (Some(bytes), Ok(Some(manifest))) => RecordRead::Present {
281 manifest: Box::new(manifest),
282 bytes: bytes.clone(),
283 },
284 (Some(_), Ok(None)) => RecordRead::Invalid {
285 reason: "the record vanished between two reads".into(),
286 },
287 (Some(_), Err(error)) => RecordRead::Invalid {
288 reason: error.to_string(),
289 },
290 };
291 refs.record = Some(ledger.observe(
292 "record",
293 EvidenceKind::Record,
294 "rk",
295 clock,
296 bytes.as_deref().map(Digest::of),
297 format!("read {}", manifest::MANIFEST_PATH),
298 ));
299 Ok(record)
300}
301
302fn read_config(
304 target: &Utf8Path,
305 clock: &str,
306 ledger: &mut Ledger,
307 refs: &mut Refs,
308) -> Result<ConfigRead, RkError> {
309 let bytes = read_optional(&target.join(crate::config::CONFIG_PATH))?;
310 let config = match (&bytes, crate::config::load(target.as_std_path())) {
311 (None, _) => ConfigRead::Absent,
312 (Some(bytes), Ok(Some(config))) => ConfigRead::Present {
313 config: Box::new(config),
314 bytes: bytes.clone(),
315 },
316 (Some(bytes), Ok(None)) => ConfigRead::Invalid {
317 reason: "the configuration vanished between two reads".into(),
318 bytes: bytes.clone(),
319 },
320 (Some(bytes), Err(error)) => ConfigRead::Invalid {
321 reason: error.to_string(),
322 bytes: bytes.clone(),
323 },
324 };
325 refs.configuration = Some(ledger.observe(
326 "configuration",
327 EvidenceKind::Configuration,
328 "rk",
329 clock,
330 bytes.as_deref().map(Digest::of),
331 format!("read {}", crate::config::CONFIG_PATH),
332 ));
333 Ok(config)
334}
335
336fn read_destinations(
339 target: &Utf8Path,
340 record: &RecordRead,
341 clock: &str,
342 ledger: &mut Ledger,
343 refs: &mut Refs,
344) -> Result<BTreeMap<String, Vec<u8>>, RkError> {
345 let mut paths: Vec<String> = landing::destinations().map(str::to_owned).collect();
346 if let RecordRead::Present { manifest, .. } = record {
347 paths.extend(manifest.files.iter().map(|file| file.destination.clone()));
348 }
349 paths.sort();
350 paths.dedup();
351 let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
352 for path in paths {
353 let bytes = landing::read_recorded(target, &path)?;
354 let id = ledger.observe(
355 format!("destination:{path}"),
356 EvidenceKind::Destination,
357 "rk",
358 clock,
359 bytes.as_deref().map(Digest::of),
360 if landing::block_markers(&path).is_some() {
361 "read the marked block"
362 } else {
363 "read the file"
364 },
365 );
366 refs.destinations.insert(path.clone(), id);
367 if let Some(bytes) = bytes {
368 files.insert(path, bytes);
369 }
370 }
371 Ok(files)
372}
373
374fn read_pin(
377 target: &Utf8Path,
378 clock: &str,
379 ledger: &mut Ledger,
380 refs: &mut Refs,
381) -> Option<PinState> {
382 let pin = crate::self_depend::observe(target)
383 .ok()
384 .and_then(|observed| {
385 let manager = observed.wired?;
386 let entry = observed.entry(manager)?;
387 Some(PinState {
388 manager: manager.as_str().to_owned(),
389 file: entry.file.clone()?,
390 version: entry.version.clone()?,
391 })
392 })?;
393 refs.pin = Some(ledger.observe(
394 "pin",
395 EvidenceKind::Pin,
396 "rk self-depend",
397 clock,
398 files_digest(target, &pin.file),
399 format!("read {}", pin.file),
400 ));
401 Some(pin)
402}
403
404pub fn resolve(request: &Request<'_>, observation: &Observation) -> Result<Resolution, RkError> {
417 let flags = request.flags;
418 let workflow_flag = flags
419 .workflow
420 .clone()
421 .or_else(|| request.decisions.get("workflow-mode").cloned());
422 let style_flag = flags
423 .style
424 .clone()
425 .or_else(|| request.decisions.get("release-style").cloned());
426 let inputs = landing::Inputs {
427 tech: flags.tech.as_deref(),
428 forge: flags.forge.as_deref(),
429 repo: flags.repo.as_deref(),
430 workflow: workflow_flag.as_deref().map(Workflow::parse).transpose()?,
431 style: style_flag.as_deref().map(Style::parse).transpose()?,
432 nix: flags.nix,
433 };
434 let config: Option<&Config> = match &observation.config {
435 ConfigRead::Present { config, .. } => Some(config),
436 ConfigRead::Absent | ConfigRead::Invalid { .. } => None,
437 };
438 let record: Option<&Manifest> = match &observation.record {
439 RecordRead::Present { manifest, .. } => Some(manifest),
440 RecordRead::Absent | RecordRead::Invalid { .. } => None,
441 };
442 let sources = sources(
443 &inputs,
444 flags,
445 request.decisions,
446 config,
447 record,
448 observation,
449 );
450 let resolved = Params::resolve(
451 request.source,
452 request.target,
453 &inputs,
454 config,
455 record,
456 landing::Purpose::Preview,
457 );
458 let (params, unresolved) = match resolved {
459 Ok(params) => (Some(params), None),
460 Err(error) => (None, Some(error.to_string())),
461 };
462 let nix_withheld = match ¶ms {
463 Some(params) if params.nix() => landing::nix_withholding(request.target, record)?
464 .map(|(set, reason)| (set.iter().map(|path| (*path).to_owned()).collect(), reason)),
465 _ => None,
466 };
467 Ok(Resolution {
468 params,
469 sources,
470 unresolved,
471 nix_withheld,
472 })
473}
474
475fn sources(
478 inputs: &landing::Inputs<'_>,
479 flags: &Flags,
480 decisions: &BTreeMap<String, String>,
481 config: Option<&Config>,
482 record: Option<&Manifest>,
483 observation: &Observation,
484) -> BTreeMap<String, String> {
485 let mut sources = BTreeMap::new();
486 let layer = |flag: bool, configured: bool, recorded: bool, detected: bool| {
487 if flag {
488 "flag"
489 } else if configured {
490 "configuration"
491 } else if recorded {
492 "record"
493 } else if detected {
494 "detected"
495 } else {
496 "default"
497 }
498 };
499 identity_sources(&mut sources, inputs, config, record, observation, layer);
500 let decided = |id: &str, flag: bool, configured: bool, recorded: bool| {
501 if flag {
502 "flag"
503 } else if decisions.contains_key(id) {
504 "decision"
505 } else if configured {
506 "configuration"
507 } else if recorded {
508 "record"
509 } else {
510 "default"
511 }
512 };
513 sources.insert(
514 "workflow".to_owned(),
515 decided(
516 "workflow-mode",
517 flags.workflow.is_some(),
518 config.is_some_and(|c| c.landing.workflow.is_some()),
519 record.is_some(),
520 )
521 .to_owned(),
522 );
523 sources.insert(
524 "style".to_owned(),
525 decided(
526 "release-style",
527 flags.style.is_some(),
528 config.is_some_and(|c| c.landing.style.is_some()),
529 record.is_some_and(|r| r.parameters.style.is_some()),
530 )
531 .to_owned(),
532 );
533 sources.insert(
534 "nix".to_owned(),
535 layer(
536 inputs.nix.is_some(),
537 config.is_some_and(|c| c.landing.nix.is_some()),
538 record.is_some(),
539 false,
540 )
541 .to_owned(),
542 );
543 for (key, configured) in [
544 ("trunk", config.is_some_and(|c| c.project.trunk.is_some())),
545 (
546 "line_prefix",
547 config.is_some_and(|c| c.setup.line_prefix.is_some()),
548 ),
549 (
550 "security_contact",
551 config.is_some_and(|c| c.security.contact.is_some()),
552 ),
553 (
554 "security_response",
555 config.is_some_and(|c| c.security.response.is_some()),
556 ),
557 ] {
558 sources.insert(
559 key.to_owned(),
560 layer(false, configured, record.is_some(), false).to_owned(),
561 );
562 }
563 sources
564}
565
566fn identity_sources(
569 sources: &mut BTreeMap<String, String>,
570 inputs: &landing::Inputs<'_>,
571 config: Option<&Config>,
572 record: Option<&Manifest>,
573 observation: &Observation,
574 layer: impl Fn(bool, bool, bool, bool) -> &'static str,
575) {
576 sources.insert(
577 "tech".to_owned(),
578 layer(
579 inputs.tech.is_some(),
580 config.is_some_and(|c| !c.project.tech.is_empty()),
581 record.is_some(),
582 observation.tech.is_some(),
583 )
584 .to_owned(),
585 );
586 sources.insert(
587 "forge".to_owned(),
588 layer(
589 inputs.forge.is_some(),
590 config.is_some_and(|c| !c.project.forge.is_empty()),
591 record.is_some(),
592 observation.forge_name.is_some(),
593 )
594 .to_owned(),
595 );
596 sources.insert(
597 "repo".to_owned(),
598 layer(
599 inputs.repo.is_some(),
600 config.is_some_and(|c| !c.project.repo.is_empty()),
601 record.is_some(),
602 observation.repo.is_some(),
603 )
604 .to_owned(),
605 );
606}
607
608fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>, RkError> {
610 match std::fs::read(path) {
611 Ok(bytes) => Ok(Some(bytes)),
612 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
613 Err(error) => Err(RkError::Io(error)),
614 }
615}
616
617fn files_digest(target: &Utf8Path, rel: &str) -> Option<Digest> {
619 std::fs::read(target.join(rel))
620 .ok()
621 .map(|bytes| Digest::of(&bytes))
622}
623
624fn remote_tip(target: &Utf8Path, trunk: &str) -> Result<Option<String>, RkError> {
630 let mut command = Command::new(crate::probes::git_bin());
631 for var in crate::maintenance::GIT_HOOK_VARS {
632 command.env_remove(var);
633 }
634 let out = command
635 .arg("-C")
636 .arg(target)
637 .args(["ls-remote", "--heads", "origin", trunk])
638 .output()
639 .map_err(|error| {
640 RkError::subprocess(
641 Diagnostic::new(
642 Reason::SubprocessSpawn,
643 format!("git could not be spawned: {error}"),
644 )
645 .expected("git on PATH, or RK_GIT_BIN naming it"),
646 )
647 })?;
648 if !out.status.success() {
649 return Err(RkError::subprocess(
650 Diagnostic::new(
651 Reason::ForgeTemporary,
652 format!(
653 "git ls-remote could not read origin: {}",
654 String::from_utf8_lossy(&out.stderr).trim()
655 ),
656 )
657 .expected("a reachable origin remote, or a plan without --observe forge"),
658 ));
659 }
660 Ok(String::from_utf8_lossy(&out.stdout)
661 .lines()
662 .find_map(|line| line.split_whitespace().next().map(str::to_owned)))
663}