230 lines
7.2 KiB
Rust
230 lines
7.2 KiB
Rust
//! A read-only view of a repository tree at one commit.
|
|||
|
|
//!
|
||
|
|
//! The catalog parser only ever talks to [`TreeView`], so it is tested
|
||
|
|
//! against [`MemTree`] with no git involved, and runs in production against
|
||
|
|
//! [`GitTree`], which reads git objects straight out of the bare cache.
|
||
|
|
|
||
|
|
use std::collections::BTreeMap;
|
||
|
|
|
||
|
|
use sha2::{Digest, Sha256};
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
|
|
pub enum EntryKind {
|
||
|
|
File,
|
||
|
|
Dir,
|
||
|
|
Symlink,
|
||
|
|
/// Anything else git can hold (submodule commits). Never installable.
|
||
|
|
Other,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
|
|
pub struct DirEntry {
|
||
|
|
pub name: String,
|
||
|
|
pub kind: EntryKind,
|
||
|
|
pub executable: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
pub trait TreeView {
|
||
|
|
/// Entries of the directory at `path` (`""` = root). `Ok(None)` if absent or not a dir.
|
||
|
|
fn list_dir(&self, path: &str) -> Result<Option<Vec<DirEntry>>, String>;
|
||
|
|
/// Contents of the regular file at `path`. `Ok(None)` if absent or not a file.
|
||
|
|
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String>;
|
||
|
|
/// Stable content id of the entry at `path`; `None` if absent.
|
||
|
|
fn entry_id(&self, path: &str) -> Result<Option<String>, String>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Hex-encode `bytes`. Shared by [`MemTree`]'s content id (test-only) and
|
||
|
|
/// `catalog::item_fingerprint`'s plugin-entry hash (production), so there is
|
||
|
|
/// one hex formatter rather than two copies of the same `format!("{:02x}")`.
|
||
|
|
pub(crate) fn hex(bytes: &[u8]) -> String {
|
||
|
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
enum MemNode {
|
||
|
|
File { data: Vec<u8>, executable: bool },
|
||
|
|
Symlink { target: String },
|
||
|
|
}
|
||
|
|
|
||
|
|
/// In-memory tree for tests: path → node. Directories are implied by paths.
|
||
|
|
#[cfg(test)]
|
||
|
|
#[derive(Debug, Clone, Default)]
|
||
|
|
pub struct MemTree {
|
||
|
|
nodes: BTreeMap<String, MemNode>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
impl MemTree {
|
||
|
|
pub fn new() -> Self {
|
||
|
|
Self::default()
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn file(mut self, path: &str, contents: &str) -> Self {
|
||
|
|
self.nodes.insert(
|
||
|
|
path.to_string(),
|
||
|
|
MemNode::File {
|
||
|
|
data: contents.as_bytes().to_vec(),
|
||
|
|
executable: false,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
self
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn exec_file(mut self, path: &str, contents: &str) -> Self {
|
||
|
|
self.nodes.insert(
|
||
|
|
path.to_string(),
|
||
|
|
MemNode::File {
|
||
|
|
data: contents.as_bytes().to_vec(),
|
||
|
|
executable: true,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
self
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn symlink(mut self, path: &str, target: &str) -> Self {
|
||
|
|
self.nodes.insert(
|
||
|
|
path.to_string(),
|
||
|
|
MemNode::Symlink {
|
||
|
|
target: target.to_string(),
|
||
|
|
},
|
||
|
|
);
|
||
|
|
self
|
||
|
|
}
|
||
|
|
|
||
|
|
fn is_dir(&self, path: &str) -> bool {
|
||
|
|
if path.is_empty() {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
let prefix = format!("{}/", path);
|
||
|
|
self.nodes.keys().any(|k| k.starts_with(&prefix))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
impl TreeView for MemTree {
|
||
|
|
fn list_dir(&self, path: &str) -> Result<Option<Vec<DirEntry>>, String> {
|
||
|
|
if self.nodes.contains_key(path) || !self.is_dir(path) {
|
||
|
|
return Ok(None);
|
||
|
|
}
|
||
|
|
let prefix = if path.is_empty() {
|
||
|
|
String::new()
|
||
|
|
} else {
|
||
|
|
format!("{}/", path)
|
||
|
|
};
|
||
|
|
let mut out: BTreeMap<String, DirEntry> = BTreeMap::new();
|
||
|
|
for (key, node) in &self.nodes {
|
||
|
|
let Some(rest) = key.strip_prefix(&prefix) else {
|
||
|
|
continue;
|
||
|
|
};
|
||
|
|
match rest.split_once('/') {
|
||
|
|
Some((dir, _)) => {
|
||
|
|
out.entry(dir.to_string()).or_insert(DirEntry {
|
||
|
|
name: dir.to_string(),
|
||
|
|
kind: EntryKind::Dir,
|
||
|
|
executable: false,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
None => {
|
||
|
|
let (kind, executable) = match node {
|
||
|
|
MemNode::File { executable, .. } => (EntryKind::File, *executable),
|
||
|
|
MemNode::Symlink { .. } => (EntryKind::Symlink, false),
|
||
|
|
};
|
||
|
|
out.insert(
|
||
|
|
rest.to_string(),
|
||
|
|
DirEntry {
|
||
|
|
name: rest.to_string(),
|
||
|
|
kind,
|
||
|
|
executable,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Ok(Some(out.into_values().collect()))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String> {
|
||
|
|
match self.nodes.get(path) {
|
||
|
|
Some(MemNode::File { data, .. }) => Ok(Some(data.clone())),
|
||
|
|
_ => Ok(None),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn entry_id(&self, path: &str) -> Result<Option<String>, String> {
|
||
|
|
let mut hasher = Sha256::new();
|
||
|
|
let mut found = false;
|
||
|
|
let prefix = format!("{}/", path);
|
||
|
|
for (key, node) in &self.nodes {
|
||
|
|
if key != path && !key.starts_with(&prefix) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
found = true;
|
||
|
|
hasher.update(key.as_bytes());
|
||
|
|
hasher.update([0]);
|
||
|
|
match node {
|
||
|
|
MemNode::File { data, executable } => {
|
||
|
|
hasher.update([if *executable { b'x' } else { b'f' }]);
|
||
|
|
hasher.update(data);
|
||
|
|
}
|
||
|
|
MemNode::Symlink { target } => {
|
||
|
|
hasher.update(b"l");
|
||
|
|
hasher.update(target.as_bytes());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
hasher.update([0]);
|
||
|
|
}
|
||
|
|
Ok(found.then(|| hex(&hasher.finalize())))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn mem_tree_lists_files_dirs_and_symlinks() {
|
||
|
|
let t = MemTree::new()
|
||
|
|
.file("agents/a.md", "x")
|
||
|
|
.exec_file("hooks/h/run.sh", "#!/bin/sh")
|
||
|
|
.symlink("agents/link.md", "a.md");
|
||
|
|
let root = t.list_dir("").unwrap().unwrap();
|
||
|
|
assert_eq!(
|
||
|
|
root.iter()
|
||
|
|
.map(|e| (e.name.as_str(), e.kind))
|
||
|
|
.collect::<Vec<_>>(),
|
||
|
|
vec![("agents", EntryKind::Dir), ("hooks", EntryKind::Dir)]
|
||
|
|
);
|
||
|
|
let agents = t.list_dir("agents").unwrap().unwrap();
|
||
|
|
assert_eq!(agents[1].kind, EntryKind::Symlink);
|
||
|
|
let hook = t.list_dir("hooks/h").unwrap().unwrap();
|
||
|
|
assert!(hook[0].executable);
|
||
|
|
assert_eq!(t.list_dir("agents/a.md").unwrap(), None);
|
||
|
|
assert_eq!(t.list_dir("missing").unwrap(), None);
|
||
|
|
assert_eq!(t.read_file("agents/a.md").unwrap().unwrap(), b"x");
|
||
|
|
assert_eq!(t.read_file("agents").unwrap(), None);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn mem_tree_entry_id_changes_only_with_content() {
|
||
|
|
let a = MemTree::new()
|
||
|
|
.file("skills/s/SKILL.md", "one")
|
||
|
|
.file("agents/x.md", "x");
|
||
|
|
let b = MemTree::new()
|
||
|
|
.file("skills/s/SKILL.md", "one")
|
||
|
|
.file("agents/x.md", "changed");
|
||
|
|
let c = MemTree::new()
|
||
|
|
.file("skills/s/SKILL.md", "two")
|
||
|
|
.file("agents/x.md", "x");
|
||
|
|
assert_eq!(
|
||
|
|
a.entry_id("skills/s").unwrap(),
|
||
|
|
b.entry_id("skills/s").unwrap()
|
||
|
|
);
|
||
|
|
assert_ne!(
|
||
|
|
a.entry_id("skills/s").unwrap(),
|
||
|
|
c.entry_id("skills/s").unwrap()
|
||
|
|
);
|
||
|
|
assert_eq!(a.entry_id("nope").unwrap(), None);
|
||
|
|
}
|
||
|
|
}
|