use super::SessionSpec;
use crate::snippets::error::{Error, Result};
use rayon::prelude::*;
use std::path::Path;
const IGNORED_DIRECTORIES: &[&str] = &[
".alef",
".dart_tool",
".git",
".gradle",
".next",
".pytest_cache",
".venv",
".zig-cache",
".zig-global-cache",
"Carthage",
"Pods",
"__pycache__",
"_build",
"bin",
"build",
"dist",
"obj",
"target",
];
pub(super) fn session_fingerprint(spec: &SessionSpec) -> Result<String> {
let mut hasher = blake3::Hasher::new();
hash_specification(&mut hasher, spec);
for digest in working_tree_digests(spec)? {
hasher.update(digest.as_bytes());
}
Ok(hasher.finalize().to_hex().to_string())
}
fn hash_specification(hasher: &mut blake3::Hasher, spec: &SessionSpec) {
hasher.update(spec.working_directory.to_string_lossy().as_bytes());
if let Some(manifest) = &spec.manifest {
hasher.update(manifest.to_string_lossy().as_bytes());
}
for command in &spec.before {
hasher.update(command.as_bytes());
}
for (name, value) in &spec.env {
hasher.update(name.as_bytes());
hasher.update(value.as_bytes());
}
for path in &spec.include_paths {
hasher.update(path.to_string_lossy().as_bytes());
}
for feature in &spec.rust_features {
hasher.update(feature.as_bytes());
}
for (name, dependency) in &spec.rust_dependencies {
hasher.update(name.as_bytes());
hasher.update(dependency.version.as_bytes());
hasher.update(&[u8::from(dependency.default_features)]);
for feature in &dependency.features {
hasher.update(feature.as_bytes());
}
}
}
fn working_tree_digests(spec: &SessionSpec) -> Result<Vec<blake3::Hash>> {
let mut files = walkdir::WalkDir::new(&spec.working_directory)
.into_iter()
.filter_entry(|entry| {
!entry.file_type().is_dir() || !IGNORED_DIRECTORIES.contains(&entry.file_name().to_string_lossy().as_ref())
})
.filter_map(std::result::Result::ok)
.filter(|entry| entry.file_type().is_file())
.map(|entry| {
let path = entry.into_path();
let relative = path
.strip_prefix(&spec.working_directory)
.unwrap_or(&path)
.to_path_buf();
(relative, path)
})
.collect::<Vec<_>>();
files.sort_by(|left, right| left.0.cmp(&right.0));
files
.into_par_iter()
.map(|(relative, path)| hash_working_tree_file(&relative, &path))
.collect()
}
fn hash_working_tree_file(relative: &Path, path: &Path) -> Result<blake3::Hash> {
let content = std::fs::read(path)
.map_err(|error| Error::Other(format!("hashing snippet session input {}: {error}", path.display())))?;
let mut hasher = blake3::Hasher::new();
hasher.update(relative.to_string_lossy().as_bytes());
hasher.update(&content);
Ok(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::snippets::types::{Language, ValidationLevel};
use std::collections::BTreeMap;
fn fingerprint_spec(working_directory: &Path) -> SessionSpec {
SessionSpec {
language: Language::TypeScript,
working_directory: working_directory.to_path_buf(),
manifest: None,
before: Vec::new(),
env: BTreeMap::new(),
include_paths: Vec::new(),
rust_features: Vec::new(),
rust_dependencies: BTreeMap::new(),
}
}
#[test]
fn the_fingerprint_is_stable_across_runs_and_tracks_source_changes() {
let directory = tempfile::tempdir().expect("temp directory");
std::fs::create_dir_all(directory.path().join("src/deep")).expect("source tree");
for name in ["src/a.ts", "src/b.ts", "src/deep/c.ts", "package.json"] {
std::fs::write(directory.path().join(name), format!("content of {name}")).expect("source file");
}
let spec = fingerprint_spec(directory.path());
let first = session_fingerprint(&spec).expect("first fingerprint");
let second = session_fingerprint(&spec).expect("second fingerprint");
assert_eq!(first, second);
std::fs::write(directory.path().join("src/b.ts"), "changed").expect("changed source");
let changed = session_fingerprint(&spec).expect("changed fingerprint");
assert_ne!(first, changed);
}
#[test]
fn build_output_directories_are_excluded_from_the_fingerprint() {
let directory = tempfile::tempdir().expect("temp directory");
std::fs::write(directory.path().join("index.ts"), "export const value = 1;").expect("source file");
let spec = fingerprint_spec(directory.path());
let baseline = session_fingerprint(&spec).expect("baseline fingerprint");
for ignored in IGNORED_DIRECTORIES {
let artifacts = directory.path().join(ignored);
std::fs::create_dir_all(&artifacts).expect("artifact directory");
std::fs::write(artifacts.join("artifact.bin"), ignored.as_bytes()).expect("build artifact");
}
assert_eq!(session_fingerprint(&spec).expect("fingerprint after build"), baseline);
}
#[test]
fn node_modules_and_vendor_contents_change_the_fingerprint() {
for package_root in ["node_modules/sample-binding", "vendor/sample-binding"] {
let directory = tempfile::tempdir().expect("temp directory");
let package = directory.path().join(package_root);
std::fs::create_dir_all(&package).expect("linked package directory");
std::fs::write(package.join("index.d.ts"), "export declare const maxChars: number;")
.expect("original binding surface");
let spec = fingerprint_spec(directory.path());
let before = session_fingerprint(&spec).expect("fingerprint before the binding surface changed");
std::fs::write(
package.join("index.d.ts"),
"export declare const maxCharacters: number;",
)
.expect("regenerated binding surface");
let after = session_fingerprint(&spec).expect("fingerprint after the binding surface changed");
assert_ne!(
before, after,
"a content change under {package_root} must change the session fingerprint, not be \
silently ignored"
);
}
}
#[test]
fn include_paths_contribute_to_the_session_fingerprint() {
let directory = tempfile::tempdir().expect("temp directory");
let base = SessionSpec {
language: Language::C,
working_directory: directory.path().to_path_buf(),
manifest: None,
before: Vec::new(),
env: BTreeMap::new(),
include_paths: vec![directory.path().join("include")],
rust_features: Vec::new(),
rust_dependencies: BTreeMap::new(),
};
let mut changed = base.clone();
changed.include_paths = vec![directory.path().join("vendor/include")];
assert_ne!(
session_fingerprint(&base).expect("base fingerprint"),
session_fingerprint(&changed).expect("changed fingerprint")
);
}
fn cached_snippet(code: &str) -> crate::snippets::types::Snippet {
let path = std::path::PathBuf::from("example.md");
crate::snippets::types::Snippet {
id: None,
path: path.clone(),
language: Language::TypeScript,
title: None,
code: code.to_string(),
start_line: 1,
block_index: 0,
annotation: None,
metadata: crate::snippets::types::SnippetMetadata::default(),
source_origin: crate::snippets::types::SourceOrigin {
path,
line: 1,
block_index: 0,
},
}
}
fn passing_result(snippet: &crate::snippets::types::Snippet) -> crate::snippets::types::ValidationResult {
crate::snippets::types::ValidationResult {
snippet: snippet.clone(),
status: crate::snippets::types::SnippetStatus::Pass,
level: ValidationLevel::Compile,
requested_level: ValidationLevel::Compile,
effective_level: ValidationLevel::Compile,
message: None,
duration_ms: 1,
capability_capped: false,
downgrade_reason: None,
unresolved_dependency: false,
}
}
#[test]
fn a_cached_pass_misses_once_the_linked_binding_package_changes() {
let directory = tempfile::tempdir().expect("temp directory");
let package = directory.path().join("node_modules/sample-binding");
std::fs::create_dir_all(&package).expect("linked package directory");
std::fs::write(
package.join("index.d.ts"),
"export declare function chunk(maxChars: number): void;",
)
.expect("original binding surface");
let spec = fingerprint_spec(directory.path());
let cache = crate::snippets::cache::ValidationCache::new(directory.path().join(".alef/snippets"));
let snippet = cached_snippet("chunk(10)");
let passing = passing_result(&snippet);
let before_fingerprint = session_fingerprint(&spec).expect("fingerprint before the binding surface changed");
cache
.store(
&snippet,
ValidationLevel::Compile,
Some(before_fingerprint.as_str()),
&passing,
)
.expect("store the passing result");
assert_eq!(
cache
.load(&snippet, ValidationLevel::Compile, Some(before_fingerprint.as_str()))
.map(|result| result.status),
Some(crate::snippets::types::SnippetStatus::Pass),
"an unchanged snippet against an unchanged binding surface must still hit the cache"
);
std::fs::write(
package.join("index.d.ts"),
"export declare function chunk(maxCharacters: number): void;",
)
.expect("regenerated binding surface");
let after_fingerprint = session_fingerprint(&spec).expect("fingerprint after the binding surface changed");
assert!(
cache
.load(&snippet, ValidationLevel::Compile, Some(after_fingerprint.as_str()))
.is_none(),
"a cached Pass must not survive a change to the linked binding package it was validated \
against"
);
}
}