use std::collections::BTreeMap;
use std::process::Command;
use camino::Utf8Path;
use crate::config::Config;
use crate::diagnostic::{Diagnostic, Reason};
use crate::digest::Digest;
use crate::error::RkError;
use crate::landing::manifest::{self, Manifest, Style, Workflow};
use crate::landing::{self, Params};
use crate::release::ReleaseSource;
use super::PinState;
use super::classify::RepositoryFacts;
use super::evidence::{EvidenceKind, Ledger};
#[derive(Debug)]
pub enum RecordRead {
Absent,
Present {
manifest: Box<Manifest>,
bytes: Vec<u8>,
},
Invalid {
reason: String,
},
}
#[derive(Debug)]
pub enum ConfigRead {
Absent,
Present {
config: Box<Config>,
bytes: Vec<u8>,
},
Invalid {
reason: String,
bytes: Vec<u8>,
},
}
#[derive(Debug)]
pub enum ForgeRead {
NotObserved {
reason: String,
},
Observed {
trunk: String,
remote_tip: Option<String>,
},
}
#[derive(Debug, Default)]
pub struct Refs {
pub repository: Vec<String>,
pub record: Option<String>,
pub configuration: Option<String>,
pub destinations: BTreeMap<String, String>,
pub host: Vec<String>,
pub pin: Option<String>,
pub forge: Vec<String>,
}
#[derive(Debug)]
pub struct Observation {
pub target: String,
pub git: bool,
pub tech: Option<String>,
pub forge_name: Option<String>,
pub repo: Option<String>,
pub facts: RepositoryFacts,
pub record: RecordRead,
pub config: ConfigRead,
pub files: BTreeMap<String, Vec<u8>>,
pub hooks_defect: Option<String>,
pub pin: Option<PinState>,
pub forge: ForgeRead,
pub ledger: Ledger,
pub refs: Refs,
}
#[derive(Debug)]
pub struct Resolution {
pub params: Option<Params>,
pub sources: BTreeMap<String, String>,
pub unresolved: Option<String>,
pub nix_withheld: Option<(Vec<String>, String)>,
}
#[derive(Debug, Default, Clone)]
pub struct Flags {
pub tech: Option<String>,
pub forge: Option<String>,
pub repo: Option<String>,
pub workflow: Option<String>,
pub style: Option<String>,
pub nix: Option<bool>,
}
pub struct Request<'a> {
pub target: &'a Utf8Path,
pub flags: &'a Flags,
pub decisions: &'a BTreeMap<String, String>,
pub observe_forge: bool,
pub clock: &'a str,
pub source: &'a dyn ReleaseSource,
}
pub fn observe(request: &Request<'_>) -> Result<Observation, RkError> {
let target = request.target;
if !target.is_dir() {
return Err(RkError::missing(
Diagnostic::new(
Reason::TargetNotFound,
format!("target {target} is not a directory"),
)
.expected("an existing repository to plan for"),
));
}
let clock = request.clock;
let mut ledger = Ledger::new();
let mut refs = Refs::default();
let record = read_record(target, clock, &mut ledger, &mut refs)?;
let config = read_config(target, clock, &mut ledger, &mut refs)?;
let facts = crate::assess::gather_facts(target)?;
refs.repository.push(ledger.observe(
"repository",
EvidenceKind::Repository,
"rk",
clock,
None,
"marker scan, payload destinations, git tag --list, git for-each-ref, version file",
));
let files = read_destinations(target, &record, clock, &mut ledger, &mut refs)?;
let hooks_defect = landing::hooks_file_defect(request.source, target)?;
refs.host.push(ledger.observe(
"host",
EvidenceKind::Host,
"rk",
clock,
None,
"the engine's own version",
));
let pin = read_pin(target, clock, &mut ledger, &mut refs);
let forge = if request.observe_forge {
let trunk = crate::config::trunk_of(target.as_std_path())?;
let remote_tip = remote_tip(target, &trunk)?;
refs.forge.push(ledger.observe(
"forge:trunk",
EvidenceKind::Forge,
"git",
clock,
None,
format!("git ls-remote --heads origin {trunk}"),
));
ForgeRead::Observed { trunk, remote_tip }
} else {
ForgeRead::NotObserved {
reason: "the forge read was not requested; --observe forge opts in".into(),
}
};
Ok(Observation {
target: target.to_string(),
git: facts.git,
tech: facts.tech.map(str::to_owned),
forge_name: facts.forge.map(str::to_owned),
repo: facts.repo.clone(),
facts: RepositoryFacts {
release_markers: facts.release_markers,
collisions: facts.collisions,
tags: facts.tags,
long_lived_branches: facts.long_lived_branches,
},
record,
config,
files,
hooks_defect,
pin,
forge,
ledger,
refs,
})
}
fn read_record(
target: &Utf8Path,
clock: &str,
ledger: &mut Ledger,
refs: &mut Refs,
) -> Result<RecordRead, RkError> {
let bytes = read_optional(&target.join(manifest::MANIFEST_PATH))?;
let record = match (&bytes, manifest::load(target)) {
(None, _) => RecordRead::Absent,
(Some(bytes), Ok(Some(manifest))) => RecordRead::Present {
manifest: Box::new(manifest),
bytes: bytes.clone(),
},
(Some(_), Ok(None)) => RecordRead::Invalid {
reason: "the record vanished between two reads".into(),
},
(Some(_), Err(error)) => RecordRead::Invalid {
reason: error.to_string(),
},
};
refs.record = Some(ledger.observe(
"record",
EvidenceKind::Record,
"rk",
clock,
bytes.as_deref().map(Digest::of),
format!("read {}", manifest::MANIFEST_PATH),
));
Ok(record)
}
fn read_config(
target: &Utf8Path,
clock: &str,
ledger: &mut Ledger,
refs: &mut Refs,
) -> Result<ConfigRead, RkError> {
let bytes = read_optional(&target.join(crate::config::CONFIG_PATH))?;
let config = match (&bytes, crate::config::load(target.as_std_path())) {
(None, _) => ConfigRead::Absent,
(Some(bytes), Ok(Some(config))) => ConfigRead::Present {
config: Box::new(config),
bytes: bytes.clone(),
},
(Some(bytes), Ok(None)) => ConfigRead::Invalid {
reason: "the configuration vanished between two reads".into(),
bytes: bytes.clone(),
},
(Some(bytes), Err(error)) => ConfigRead::Invalid {
reason: error.to_string(),
bytes: bytes.clone(),
},
};
refs.configuration = Some(ledger.observe(
"configuration",
EvidenceKind::Configuration,
"rk",
clock,
bytes.as_deref().map(Digest::of),
format!("read {}", crate::config::CONFIG_PATH),
));
Ok(config)
}
fn read_destinations(
target: &Utf8Path,
record: &RecordRead,
clock: &str,
ledger: &mut Ledger,
refs: &mut Refs,
) -> Result<BTreeMap<String, Vec<u8>>, RkError> {
let mut paths: Vec<String> = landing::destinations().map(str::to_owned).collect();
if let RecordRead::Present { manifest, .. } = record {
paths.extend(manifest.files.iter().map(|file| file.destination.clone()));
}
paths.sort();
paths.dedup();
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
for path in paths {
let bytes = landing::read_recorded(target, &path)?;
let id = ledger.observe(
format!("destination:{path}"),
EvidenceKind::Destination,
"rk",
clock,
bytes.as_deref().map(Digest::of),
if landing::block_markers(&path).is_some() {
"read the marked block"
} else {
"read the file"
},
);
refs.destinations.insert(path.clone(), id);
if let Some(bytes) = bytes {
files.insert(path, bytes);
}
}
Ok(files)
}
fn read_pin(
target: &Utf8Path,
clock: &str,
ledger: &mut Ledger,
refs: &mut Refs,
) -> Option<PinState> {
let pin = crate::self_depend::observe(target)
.ok()
.and_then(|observed| {
let manager = observed.wired?;
let entry = observed.entry(manager)?;
Some(PinState {
manager: manager.as_str().to_owned(),
file: entry.file.clone()?,
version: entry.version.clone()?,
})
})?;
refs.pin = Some(ledger.observe(
"pin",
EvidenceKind::Pin,
"rk self-depend",
clock,
files_digest(target, &pin.file),
format!("read {}", pin.file),
));
Some(pin)
}
pub fn resolve(request: &Request<'_>, observation: &Observation) -> Result<Resolution, RkError> {
let flags = request.flags;
let workflow_flag = flags
.workflow
.clone()
.or_else(|| request.decisions.get("workflow-mode").cloned());
let style_flag = flags
.style
.clone()
.or_else(|| request.decisions.get("release-style").cloned());
let inputs = landing::Inputs {
tech: flags.tech.as_deref(),
forge: flags.forge.as_deref(),
repo: flags.repo.as_deref(),
workflow: workflow_flag.as_deref().map(Workflow::parse).transpose()?,
style: style_flag.as_deref().map(Style::parse).transpose()?,
nix: flags.nix,
};
let config: Option<&Config> = match &observation.config {
ConfigRead::Present { config, .. } => Some(config),
ConfigRead::Absent | ConfigRead::Invalid { .. } => None,
};
let record: Option<&Manifest> = match &observation.record {
RecordRead::Present { manifest, .. } => Some(manifest),
RecordRead::Absent | RecordRead::Invalid { .. } => None,
};
let sources = sources(
&inputs,
flags,
request.decisions,
config,
record,
observation,
);
let resolved = Params::resolve(
request.source,
request.target,
&inputs,
config,
record,
landing::Purpose::Preview,
);
let (params, unresolved) = match resolved {
Ok(params) => (Some(params), None),
Err(error) => (None, Some(error.to_string())),
};
let nix_withheld = match ¶ms {
Some(params) if params.nix() => landing::nix_withholding(request.target, record)?
.map(|(set, reason)| (set.iter().map(|path| (*path).to_owned()).collect(), reason)),
_ => None,
};
Ok(Resolution {
params,
sources,
unresolved,
nix_withheld,
})
}
fn sources(
inputs: &landing::Inputs<'_>,
flags: &Flags,
decisions: &BTreeMap<String, String>,
config: Option<&Config>,
record: Option<&Manifest>,
observation: &Observation,
) -> BTreeMap<String, String> {
let mut sources = BTreeMap::new();
let layer = |flag: bool, configured: bool, recorded: bool, detected: bool| {
if flag {
"flag"
} else if configured {
"configuration"
} else if recorded {
"record"
} else if detected {
"detected"
} else {
"default"
}
};
identity_sources(&mut sources, inputs, config, record, observation, layer);
let decided = |id: &str, flag: bool, configured: bool, recorded: bool| {
if flag {
"flag"
} else if decisions.contains_key(id) {
"decision"
} else if configured {
"configuration"
} else if recorded {
"record"
} else {
"default"
}
};
sources.insert(
"workflow".to_owned(),
decided(
"workflow-mode",
flags.workflow.is_some(),
config.is_some_and(|c| c.landing.workflow.is_some()),
record.is_some(),
)
.to_owned(),
);
sources.insert(
"style".to_owned(),
decided(
"release-style",
flags.style.is_some(),
config.is_some_and(|c| c.landing.style.is_some()),
record.is_some_and(|r| r.parameters.style.is_some()),
)
.to_owned(),
);
sources.insert(
"nix".to_owned(),
layer(
inputs.nix.is_some(),
config.is_some_and(|c| c.landing.nix.is_some()),
record.is_some(),
false,
)
.to_owned(),
);
for (key, configured) in [
("trunk", config.is_some_and(|c| c.project.trunk.is_some())),
(
"line_prefix",
config.is_some_and(|c| c.setup.line_prefix.is_some()),
),
(
"security_contact",
config.is_some_and(|c| c.security.contact.is_some()),
),
(
"security_response",
config.is_some_and(|c| c.security.response.is_some()),
),
] {
sources.insert(
key.to_owned(),
layer(false, configured, record.is_some(), false).to_owned(),
);
}
sources
}
fn identity_sources(
sources: &mut BTreeMap<String, String>,
inputs: &landing::Inputs<'_>,
config: Option<&Config>,
record: Option<&Manifest>,
observation: &Observation,
layer: impl Fn(bool, bool, bool, bool) -> &'static str,
) {
sources.insert(
"tech".to_owned(),
layer(
inputs.tech.is_some(),
config.is_some_and(|c| !c.project.tech.is_empty()),
record.is_some(),
observation.tech.is_some(),
)
.to_owned(),
);
sources.insert(
"forge".to_owned(),
layer(
inputs.forge.is_some(),
config.is_some_and(|c| !c.project.forge.is_empty()),
record.is_some(),
observation.forge_name.is_some(),
)
.to_owned(),
);
sources.insert(
"repo".to_owned(),
layer(
inputs.repo.is_some(),
config.is_some_and(|c| !c.project.repo.is_empty()),
record.is_some(),
observation.repo.is_some(),
)
.to_owned(),
);
}
fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>, RkError> {
match std::fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(RkError::Io(error)),
}
}
fn files_digest(target: &Utf8Path, rel: &str) -> Option<Digest> {
std::fs::read(target.join(rel))
.ok()
.map(|bytes| Digest::of(&bytes))
}
fn remote_tip(target: &Utf8Path, trunk: &str) -> Result<Option<String>, RkError> {
let mut command = Command::new(crate::probes::git_bin());
for var in crate::maintenance::GIT_HOOK_VARS {
command.env_remove(var);
}
let out = command
.arg("-C")
.arg(target)
.args(["ls-remote", "--heads", "origin", trunk])
.output()
.map_err(|error| {
RkError::subprocess(
Diagnostic::new(
Reason::SubprocessSpawn,
format!("git could not be spawned: {error}"),
)
.expected("git on PATH, or RK_GIT_BIN naming it"),
)
})?;
if !out.status.success() {
return Err(RkError::subprocess(
Diagnostic::new(
Reason::ForgeTemporary,
format!(
"git ls-remote could not read origin: {}",
String::from_utf8_lossy(&out.stderr).trim()
),
)
.expected("a reachable origin remote, or a plan without --observe forge"),
));
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.find_map(|line| line.split_whitespace().next().map(str::to_owned)))
}