use std::path::Path;
use crate::error::Result;
use crate::git;
use crate::rules::{RulesFile, TrailerPrefix};
use crate::session::Provenance;
pub const DEFAULT_PREFIX: &str = "Onevcs-";
pub const ATTESTATION_SUBJECT: &str = "chore: attest verified recovery of preserved work";
pub const INCOMPLETE_SUFFIX: &str = "(incomplete step)";
const STATUS: &str = "Status";
const INCOMPLETE: &str = "incomplete";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Trailers {
prefix: TrailerPrefix,
incomplete: String,
change_base: String,
recovered: String,
change_url: String,
}
impl Default for Trailers {
fn default() -> Self {
Self::new(&TrailerPrefix::default())
}
}
impl Trailers {
pub fn new(prefix: &TrailerPrefix) -> Self {
Self {
prefix: prefix.clone(),
incomplete: format!("{prefix}{STATUS}: {INCOMPLETE}"),
change_base: format!("{prefix}Change-Base:"),
recovered: format!("{prefix}Recovered-Incomplete:"),
change_url: format!("{prefix}Change-Url:"),
}
}
pub fn prefix(&self) -> &TrailerPrefix {
&self.prefix
}
pub fn incomplete(&self) -> &str {
&self.incomplete
}
pub fn change_base(&self) -> &str {
&self.change_base
}
pub fn recovered(&self) -> &str {
&self.recovered
}
pub fn change_url(&self) -> &str {
&self.change_url
}
}
pub fn validate_prefix(prefix: &str) -> std::result::Result<(), String> {
if prefix.is_empty() {
return Err(
"it is empty, and the prefix is what keeps a repository's own trailers from being \
mistaken for provenance"
.to_owned(),
);
}
if let Some(bad) = prefix
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || *c == '-'))
{
return Err(format!(
"{bad:?} is not a character a git trailer key may carry; use letters, digits, and '-'"
));
}
if !prefix.starts_with(|c: char| c.is_ascii_alphanumeric()) {
return Err("a git trailer key starts with a letter or a digit".to_owned());
}
Ok(())
}
pub fn from_rules(file: &RulesFile) -> Trailers {
match file.trailer_prefix.as_ref() {
Some(prefix) => Trailers::new(prefix),
None => Trailers::default(),
}
}
pub fn configured() -> Result<Trailers> {
let registry = crate::store::load()?;
let (file, _source) = crate::policy::load(®istry)?;
Ok(from_rules(&file))
}
pub fn is_incomplete(message: &str, trailers: &Trailers) -> bool {
message.contains(trailers.incomplete()) || message.contains(INCOMPLETE_SUFFIX)
}
pub fn is_provenance(message: &str, trailers: &Trailers) -> bool {
is_incomplete(message, trailers) || message.contains(trailers.recovered())
}
pub fn incomplete_message(summary: &str, change_base: Option<&str>, trailers: &Trailers) -> String {
let mut message = format!(
"chore: preserve {summary} {INCOMPLETE_SUFFIX}\n\n\
Preserved by onevcs after the session did not complete.\n\n{}",
trailers.incomplete()
);
if let Some(base) = change_base {
message.push_str(&format!("\n{} {base}", trailers.change_base()));
}
message
}
pub fn unattested(
repo: &Path,
base: &str,
branch: &str,
trailers: &Trailers,
) -> Result<Vec<String>> {
let commits = git::log_messages(repo, base, branch)?;
let recovered = attested_shas(&commits, trailers);
Ok(commits
.iter()
.filter(|commit| {
is_incomplete(&commit.message, trailers) && !recovered.contains(&commit.sha)
})
.map(|commit| commit.sha.clone())
.collect())
}
pub fn unrecognized(
repo: &Path,
base: &str,
branch: &str,
trailers: &Trailers,
) -> Result<Vec<TrailerPrefix>> {
let commits = git::log_messages(repo, base, branch)?;
let mut found: Vec<TrailerPrefix> = Vec::new();
for line in commits.iter().flat_map(|commit| commit.message.lines()) {
let Some(prefix) = marker_prefix(line) else {
continue;
};
if &prefix != trailers.prefix() && !found.contains(&prefix) {
found.push(prefix);
}
}
Ok(found)
}
fn marker_prefix(line: &str) -> Option<TrailerPrefix> {
let (key, value) = line.trim().split_once(':')?;
if value.trim() != INCOMPLETE {
return None;
}
TrailerPrefix::try_from(key.strip_suffix(STATUS)?.to_owned()).ok()
}
pub fn attestation_trailers(
repo: &Path,
base: &str,
branch: &str,
trailers: &Trailers,
) -> Result<Vec<String>> {
let commits = git::log_messages(repo, base, branch)?;
let recovered = attested_shas(&commits, trailers);
Ok(commits
.iter()
.filter(|commit| {
is_incomplete(&commit.message, trailers) && recovered.contains(&commit.sha)
})
.map(|commit| format!("{} {}", trailers.recovered(), commit.sha))
.collect())
}
fn attested_shas(commits: &[git::CommitMessage], trailers: &Trailers) -> Vec<String> {
commits
.iter()
.flat_map(|commit| commit.message.lines())
.filter_map(|line| line.trim().strip_prefix(trailers.recovered()))
.map(|sha| sha.trim().to_owned())
.collect()
}
pub fn recorded_change_base(
repo: &Path,
base: &str,
branch: &str,
trailers: &Trailers,
) -> Result<Option<String>> {
let commits = git::log_messages(repo, base, branch)?;
for commit in commits.iter().rev() {
if !is_incomplete(&commit.message, trailers) {
continue;
}
let recorded: Vec<String> = commit
.message
.lines()
.filter_map(|line| line.trim().strip_prefix(trailers.change_base()))
.map(|value| value.trim().to_owned())
.collect();
return Ok(recorded.into_iter().find(|value| !value.is_empty()));
}
Ok(None)
}
pub fn attest(repo: &Path, base: &str, trailers: &Trailers) -> Result<Option<String>> {
let mut missing = unattested(repo, base, "HEAD", trailers)?;
if missing.is_empty() {
return Ok(None);
}
missing.sort();
let attested: Vec<String> = missing
.iter()
.map(|sha| format!("{} {sha}", trailers.recovered()))
.collect();
git::commit_empty(
repo,
&format!("{ATTESTATION_SUBJECT}\n\n{}", attested.join("\n")),
)
.map(Some)
}
pub fn provenance_of(
repo: &Path,
base: &str,
branch: &str,
trailers: &Trailers,
) -> Result<Provenance> {
let commits = git::log_messages(repo, base, branch)?;
Ok(
if commits
.iter()
.any(|commit| is_incomplete(&commit.message, trailers))
{
Provenance::IncompleteStep
} else {
Provenance::Complete
},
)
}
pub fn checked_subject(title: &str) -> std::result::Result<String, String> {
let title = title.trim();
if title.is_empty() {
Err("the explicit title is blank".to_owned())
} else if title.len() <= SUBJECT_LIMIT {
Ok(title.to_owned())
} else {
Err(format!(
"the explicit title is {} characters, over the {SUBJECT_LIMIT}-character limit",
title.len()
))
}
}
pub fn publication_subject(
repo: &Path,
base: &str,
branch: &str,
explicit: Option<&str>,
trailers: &Trailers,
) -> Result<std::result::Result<String, String>> {
if let Some(title) = explicit {
return Ok(checked_subject(title));
}
let commits = git::log_messages(repo, base, branch)?;
let describing: Vec<&git::CommitMessage> = commits
.iter()
.filter(|commit| !is_provenance(&commit.message, trailers))
.collect();
if describing.is_empty() {
return Ok(Err(format!(
"branch {branch:?} has no commit that describes a change"
)));
}
let mut ranked: Vec<(u8, &str)> = describing
.iter()
.filter_map(|commit| commit.message.lines().next())
.map(|subject| (significance(subject), subject))
.collect();
ranked.sort_by_key(|(rank, _)| std::cmp::Reverse(*rank));
match ranked
.iter()
.find(|(_, subject)| subject.len() <= SUBJECT_LIMIT)
{
Some((_, subject)) => Ok(Ok((*subject).to_owned())),
None => Ok(Err(format!(
"no commit subject on branch {branch:?} fits the {SUBJECT_LIMIT}-character limit; \
shorten one, or publish with --title"
))),
}
}
pub const SUBJECT_LIMIT: usize = 72;
fn significance(subject: &str) -> u8 {
let kind = subject.split_once(':').map(|(kind, _)| kind).unwrap_or("");
if kind.contains('!') {
return 6;
}
match kind.split('(').next().unwrap_or("") {
"feat" => 5,
"fix" => 4,
"perf" => 3,
"refactor" => 2,
"docs" | "test" | "build" | "ci" | "style" => 1,
_ => 0,
}
}