use std::collections::{BTreeMap, BTreeSet, HashMap};
use lex_vcs::{OpId, OpLog, OperationKind, OperationRecord};
use serde::{Deserialize, Serialize};
use crate::{Store, StoreError};
pub use lex_vcs::BlobId;
pub const MANIFEST_VERSION: u32 = 1;
pub const MODE_FILE: &str = "100644";
pub const MODE_EXEC: &str = "100755";
pub fn is_blob_id(s: &str) -> bool {
s.len() == 64
&& s.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Entry {
pub blob: BlobId,
pub mode: String,
pub size: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub version: u32,
pub entries: BTreeMap<String, Entry>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ManifestError {
#[error("manifest is not valid JSON of the expected shape: {0}")]
Malformed(String),
#[error("manifest is not in canonical form")]
NotCanonical,
#[error("unsupported manifest version {0}")]
UnsupportedVersion(u32),
#[error("invalid path `{path}`: {reason}")]
InvalidPath { path: String, reason: &'static str },
#[error("path `{0}` is owned by the op-log (src/**/*.lex, src.lex)")]
ReservedPath(String),
#[error("paths `{0}` and `{1}` differ only in case")]
CaseCollision(String, String),
#[error("`{0}` is a file but `{1}` needs it to be a directory")]
FileDirCollision(String, String),
#[error("`{path}`: unsupported mode `{mode}` (only 100644 and 100755)")]
InvalidMode { path: String, mode: String },
#[error("`{path}`: `{blob}` is not a blob id")]
InvalidBlobId { path: String, blob: String },
#[error("`{path}`: manifest says {expected} byte(s), blob holds {actual}")]
SizeMismatch {
path: String,
expected: u64,
actual: u64,
},
}
pub fn is_reserved_path(path: &str) -> bool {
path == "src.lex" || (path.starts_with("src/") && path.ends_with(".lex"))
}
pub fn validate_path(path: &str) -> Result<(), ManifestError> {
let bad = |reason| {
Err(ManifestError::InvalidPath {
path: path.to_string(),
reason,
})
};
if path.is_empty() {
return bad("empty");
}
if path.starts_with('/') {
return bad("absolute");
}
if path.contains('\\') {
return bad("contains `\\`");
}
if path.contains('\0') {
return bad("contains NUL");
}
for (i, comp) in path.split('/').enumerate() {
match comp {
"" => return bad("empty component"),
"." | ".." => return bad("`.` or `..` component"),
c if c.eq_ignore_ascii_case(".git") => return bad("`.git` component"),
c if i == 0 && c.eq_ignore_ascii_case(".lex") => return bad("`.lex` store directory"),
_ => {}
}
}
if is_reserved_path(path) {
return Err(ManifestError::ReservedPath(path.to_string()));
}
Ok(())
}
impl Manifest {
pub fn new() -> Self {
Manifest {
version: MANIFEST_VERSION,
entries: BTreeMap::new(),
}
}
pub fn validate(&self) -> Result<(), ManifestError> {
if self.version != MANIFEST_VERSION {
return Err(ManifestError::UnsupportedVersion(self.version));
}
let mut folded: BTreeMap<String, &str> = BTreeMap::new();
for (path, e) in &self.entries {
validate_path(path)?;
if e.mode != MODE_FILE && e.mode != MODE_EXEC {
return Err(ManifestError::InvalidMode {
path: path.clone(),
mode: e.mode.clone(),
});
}
if !is_blob_id(&e.blob) {
return Err(ManifestError::InvalidBlobId {
path: path.clone(),
blob: e.blob.clone(),
});
}
if let Some(prev) = folded.insert(path.to_lowercase(), path) {
return Err(ManifestError::CaseCollision(prev.to_string(), path.clone()));
}
}
let files: BTreeSet<&str> = self.entries.keys().map(String::as_str).collect();
for path in &files {
let mut end = 0;
while let Some(i) = path[end..].find('/') {
end += i;
let dir = &path[..end];
if files.contains(dir) {
return Err(ManifestError::FileDirCollision(
dir.to_string(),
path.to_string(),
));
}
end += 1;
}
}
Ok(())
}
pub fn to_canonical_bytes(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("manifest serialization is infallible")
}
pub fn id(&self) -> BlobId {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(self.to_canonical_bytes()))
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, ManifestError> {
let m: Manifest =
serde_json::from_slice(bytes).map_err(|e| ManifestError::Malformed(e.to_string()))?;
if m.to_canonical_bytes() != bytes {
return Err(ManifestError::NotCanonical);
}
m.validate()?;
Ok(m)
}
}
impl Default for Manifest {
fn default() -> Self {
Self::new()
}
}
impl Store {
pub fn put_manifest(&self, manifest: &Manifest) -> Result<BlobId, StoreError> {
manifest.validate().map_err(StoreError::InvalidManifest)?;
self.put_blob_bytes(&manifest.to_canonical_bytes())
}
pub fn get_manifest(&self, id: &str) -> Result<Manifest, StoreError> {
Manifest::from_bytes(&self.get_blob_bytes(id)?).map_err(StoreError::InvalidManifest)
}
pub fn manifest_closure_missing(&self, manifest: &Manifest) -> Vec<BlobId> {
let ids: BTreeSet<&BlobId> = manifest.entries.values().map(|e| &e.blob).collect();
ids.into_iter()
.filter(|id| !self.has_blob(id))
.cloned()
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, thiserror::Error)]
#[serde(rename_all = "snake_case")]
pub enum NotReplayable {
#[error("files snapshot (set_files) — not a program change")]
Files,
}
pub(crate) fn refuse_non_semantic(rec: &OperationRecord) -> Result<(), StoreError> {
match rec.op.kind {
OperationKind::SetFiles { .. } => Err(StoreError::NotReplayable {
op_id: rec.op_id.clone(),
why: NotReplayable::Files,
}),
_ => Ok(()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum ManifestAt {
Absent,
Set { manifest: BlobId },
Ambiguous,
}
impl ManifestAt {
pub fn manifest(&self) -> Option<&BlobId> {
match self {
ManifestAt::Set { manifest } => Some(manifest),
_ => None,
}
}
}
fn combine<'a>(mut parents: impl Iterator<Item = &'a ManifestAt>) -> ManifestAt {
let Some(first) = parents.next() else {
return ManifestAt::Absent;
};
if parents.all(|p| p == first) {
first.clone()
} else {
ManifestAt::Ambiguous
}
}
pub(crate) fn resolve_manifest_at(
log: &OpLog,
op: &OpId,
memo: &mut HashMap<OpId, ManifestAt>,
pre: &HashMap<&str, &OperationRecord>,
) -> Result<ManifestAt, StoreError> {
let mut parents_of: HashMap<OpId, Vec<OpId>> = HashMap::new();
let mut stack: Vec<OpId> = vec![op.clone()];
while let Some(id) = stack.last().cloned() {
if memo.contains_key(&id) {
stack.pop();
continue;
}
if !parents_of.contains_key(&id) {
let loaded;
let rec = match pre.get(id.as_str()) {
Some(r) => Some(*r),
None => {
loaded = log.get(&id)?;
loaded.as_ref()
}
};
match rec {
None => {
memo.insert(id, ManifestAt::Absent);
stack.pop();
continue;
}
Some(r) => {
if let OperationKind::SetFiles { manifest } = &r.op.kind {
memo.insert(
id,
ManifestAt::Set {
manifest: manifest.clone(),
},
);
stack.pop();
continue;
}
parents_of.insert(id.clone(), r.op.parents.clone());
}
}
}
let parents = &parents_of[&id];
let pending: Vec<OpId> = parents
.iter()
.filter(|p| !memo.contains_key(*p))
.cloned()
.collect();
if !pending.is_empty() {
stack.extend(pending);
continue;
}
let value = combine(parents.iter().map(|p| &memo[p]));
memo.insert(id, value);
stack.pop();
}
Ok(memo[op].clone())
}
impl Store {
pub fn manifest_at(&self, op_id: &str) -> Result<ManifestAt, StoreError> {
let log = OpLog::open(self.root())?;
let op_id = op_id.to_string();
if log.get(&op_id)?.is_none() {
return Err(StoreError::UnknownOp(op_id));
}
resolve_manifest_at(&log, &op_id, &mut HashMap::new(), &HashMap::new())
}
pub fn validate_set_files(&self, manifest: &str) -> Result<Manifest, StoreError> {
if !self.has_blob(manifest) {
return Err(StoreError::MissingBlobs(vec![manifest.to_string()]));
}
let m = self.get_manifest(manifest)?;
let missing = self.manifest_closure_missing(&m);
if !missing.is_empty() {
return Err(StoreError::MissingBlobs(missing));
}
for (path, e) in &m.entries {
let actual = self.blob_len(&e.blob).unwrap_or(0);
if actual != e.size {
return Err(StoreError::InvalidManifest(ManifestError::SizeMismatch {
path: path.clone(),
expected: e.size,
actual,
}));
}
}
Ok(m)
}
pub fn apply_set_files(
&self,
branch: &str,
manifest: &str,
intent_id: Option<&lex_vcs::IntentId>,
) -> Result<OpId, StoreError> {
self.validate_set_files(manifest)?;
let head = self.get_branch(branch)?.and_then(|b| b.head_op);
let mut op = lex_vcs::Operation::new(
OperationKind::SetFiles {
manifest: manifest.to_string(),
},
head,
);
if let Some(i) = intent_id {
op = op.with_intent(i.clone());
}
self.apply_operation(branch, op, lex_vcs::StageTransition::FilesOnly)
}
}
impl Store {
fn entry_to_file_entry(e: &Entry) -> lex_vcs::FileEntry {
lex_vcs::FileEntry { blob: e.blob.clone(), mode: e.mode.clone(), size: e.size }
}
fn manifest_for_at(&self, at: &ManifestAt) -> Result<Manifest, StoreError> {
match at {
ManifestAt::Absent | ManifestAt::Ambiguous => Ok(Manifest::new()),
ManifestAt::Set { manifest } => self.get_manifest(manifest),
}
}
pub fn manifest_merge(
&self,
base_head: Option<&str>,
ours_head: Option<&str>,
theirs_head: Option<&str>,
) -> Result<ManifestMergeOutcome, StoreError> {
let ours_at = match ours_head {
Some(h) => self.manifest_at(h)?,
None => ManifestAt::Absent,
};
let theirs_at = match theirs_head {
Some(h) => self.manifest_at(h)?,
None => ManifestAt::Absent,
};
if ours_at == theirs_at {
return Ok(ManifestMergeOutcome::NoChange);
}
if matches!(ours_at, ManifestAt::Ambiguous) {
return Err(StoreError::AmbiguousManifest {
op_id: ours_head.unwrap_or_default().to_string(),
});
}
if matches!(theirs_at, ManifestAt::Ambiguous) {
return Err(StoreError::AmbiguousManifest {
op_id: theirs_head.unwrap_or_default().to_string(),
});
}
let base_at = match base_head {
Some(h) => self.manifest_at(h)?,
None => ManifestAt::Absent,
};
let base_m = self.manifest_for_at(&base_at)?;
let ours_m = self.manifest_for_at(&ours_at)?;
let theirs_m = self.manifest_for_at(&theirs_at)?;
let paths: BTreeSet<&String> = base_m
.entries
.keys()
.chain(ours_m.entries.keys())
.chain(theirs_m.entries.keys())
.collect();
let mut auto_entries: BTreeMap<String, Entry> = BTreeMap::new();
let mut conflicts: Vec<lex_vcs::FileConflict> = Vec::new();
for path in paths {
let b = base_m.entries.get(path);
let o = ours_m.entries.get(path);
let t = theirs_m.entries.get(path);
if o == t {
if let Some(e) = o {
auto_entries.insert(path.clone(), e.clone());
}
continue;
}
if o == b {
if let Some(e) = t {
auto_entries.insert(path.clone(), e.clone());
}
continue;
}
if t == b {
if let Some(e) = o {
auto_entries.insert(path.clone(), e.clone());
}
continue;
}
conflicts.push(lex_vcs::FileConflict {
path: path.clone(),
base: b.map(Self::entry_to_file_entry),
ours: o.map(Self::entry_to_file_entry),
theirs: t.map(Self::entry_to_file_entry),
});
}
Ok(ManifestMergeOutcome::Needed { auto_entries, conflicts })
}
pub fn build_merged_manifest(
&self,
auto_entries: BTreeMap<String, Entry>,
conflicts: &[lex_vcs::FileConflict],
resolutions: &BTreeMap<lex_vcs::FilePath, lex_vcs::FileResolution>,
merge_op_id: &str,
) -> Result<BlobId, StoreError> {
let mut entries = auto_entries;
for c in conflicts {
let chosen = match resolutions.get(&c.path) {
Some(lex_vcs::FileResolution::TakeOurs) => &c.ours,
Some(lex_vcs::FileResolution::TakeTheirs) => &c.theirs,
Some(lex_vcs::FileResolution::Defer) | None => {
return Err(StoreError::AmbiguousManifest {
op_id: merge_op_id.to_string(),
});
}
};
match chosen {
Some(fe) => {
entries.insert(
c.path.clone(),
Entry { blob: fe.blob.clone(), mode: fe.mode.clone(), size: fe.size },
);
}
None => {
entries.remove(&c.path);
}
}
}
let manifest = Manifest { version: MANIFEST_VERSION, entries };
self.put_manifest(&manifest)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestMergeOutcome {
NoChange,
Needed {
auto_entries: BTreeMap<String, Entry>,
conflicts: Vec<lex_vcs::FileConflict>,
},
}