use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use include_dir::{Dir, include_dir};
static PROBE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/embedded/probe");
const DEST: &str = "scripts/teksilo_probe";
const MANIFEST: &str = ".teksilo-probe-manifest";
#[derive(Debug, thiserror::Error)]
pub enum ProbeError {
#[error("could not write the probe harness: {0}")]
Io(#[from] std::io::Error),
#[error(
"{count} generated file(s) have local edits:\n{files}\n\
Re-run with `--force` to overwrite them, or move your changes into\n\
`scripts/` (outside `{DEST}`), which this tool never touches."
)]
LocallyModified { count: usize, files: String },
#[error("could not update {0}: {1}")]
Manifest(PathBuf, String),
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Written {
pub created: Vec<String>,
pub updated: Vec<String>,
pub unchanged: Vec<String>,
}
impl Written {
pub fn total(&self) -> usize {
self.created.len() + self.updated.len() + self.unchanged.len()
}
}
pub fn materialise(project: &Path, force: bool) -> Result<Written, ProbeError> {
let dest = project.join(DEST);
let previous = read_manifest(&dest);
if !force && !previous.is_empty() {
let modified = locally_modified(&dest, &previous);
if !modified.is_empty() {
return Err(ProbeError::LocallyModified {
count: modified.len(),
files: modified
.iter()
.map(|f| format!(" {DEST}/{f}"))
.collect::<Vec<_>>()
.join("\n"),
});
}
}
let mut written = Written::default();
let mut manifest = BTreeMap::new();
for file in walk(&PROBE) {
let rel = shipped_path(file.path());
let body = file.contents();
let target = dest.join(&rel);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let existed = target.exists();
let same = existed && std::fs::read(&target).map(|c| c == body).unwrap_or(false);
if !same {
std::fs::write(&target, body)?;
}
manifest.insert(rel.clone(), digest(body));
if !existed {
written.created.push(rel);
} else if same {
written.unchanged.push(rel);
} else {
written.updated.push(rel);
}
}
for old in previous.keys() {
if !manifest.contains_key(old) {
let _ = std::fs::remove_file(dest.join(old));
}
}
write_manifest(&dest, &manifest)?;
Ok(written)
}
pub fn record_provenance(project: &Path, version: &str) -> Result<(), ProbeError> {
let manifest_path = project.join("Cargo.toml");
let text = std::fs::read_to_string(&manifest_path)?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| ProbeError::Manifest(manifest_path.clone(), e.to_string()))?;
let meta = doc
.entry("package")
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
.as_table_mut()
.ok_or_else(|| {
ProbeError::Manifest(manifest_path.clone(), "[package] is not a table".into())
})?
.entry("metadata")
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
.as_table_mut()
.ok_or_else(|| {
ProbeError::Manifest(
manifest_path.clone(),
"[package.metadata] is not a table".into(),
)
})?
.entry("teksilo")
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
.as_table_mut()
.ok_or_else(|| {
ProbeError::Manifest(
manifest_path.clone(),
"[package.metadata.teksilo] is not a table".into(),
)
})?;
meta["probe"] = toml_edit::value(version);
std::fs::write(&manifest_path, doc.to_string())?;
Ok(())
}
pub fn recorded_provenance(project: &Path) -> Option<String> {
let text = std::fs::read_to_string(project.join("Cargo.toml")).ok()?;
let doc = text.parse::<toml_edit::DocumentMut>().ok()?;
doc.get("package")?
.get("metadata")?
.get("teksilo")?
.get("probe")?
.as_str()
.map(str::to_string)
}
fn ships(path: &Path) -> bool {
let p = path.to_string_lossy();
!p.starts_with("tests/") && p != "generate_tools.py"
}
fn shipped_path(path: &Path) -> String {
let p = path.to_string_lossy();
p.strip_prefix("teksilo_probe/").unwrap_or(&p).to_string()
}
fn walk<'a>(dir: &'a Dir<'a>) -> Vec<&'a include_dir::File<'a>> {
let mut out: Vec<_> = dir.files().filter(|f| ships(f.path())).collect();
for d in dir.dirs() {
if ships(d.path()) {
out.extend(walk(d));
}
}
out.sort_by_key(|f| f.path());
out
}
fn digest(bytes: &[u8]) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in bytes {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{hash:016x}")
}
fn read_manifest(dest: &Path) -> BTreeMap<String, String> {
let Ok(text) = std::fs::read_to_string(dest.join(MANIFEST)) else {
return BTreeMap::new();
};
text.lines()
.filter(|line| !line.trim_start().starts_with('#') && !line.trim().is_empty())
.filter_map(|line| line.split_once(" "))
.map(|(h, f)| (f.trim().to_string(), h.trim().to_string()))
.collect()
}
fn write_manifest(dest: &Path, manifest: &BTreeMap<String, String>) -> Result<(), ProbeError> {
let mut out = String::from(
"# Written by `cargo teksilo probe`. Do not edit.\n\
# Lists what this tool generated, so a later run can tell its own\n\
# output from your edits. Your own probes belong in scripts/, not here.\n",
);
for (file, hash) in manifest {
out.push_str(&format!("{hash} {file}\n"));
}
std::fs::write(dest.join(MANIFEST), out)?;
Ok(())
}
fn locally_modified(dest: &Path, previous: &BTreeMap<String, String>) -> Vec<String> {
let mut modified: Vec<String> = previous
.iter()
.filter(|(file, recorded)| {
std::fs::read(dest.join(file))
.map(|body| digest(&body) != **recorded)
.unwrap_or(false) })
.map(|(file, _)| file.clone())
.collect();
modified.sort();
modified
}
#[cfg(test)]
mod tests {
use super::*;
fn project() -> tempfile::TempDir {
let t = tempfile::tempdir().unwrap();
std::fs::write(
t.path().join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[dependencies]\nteksilo = \"0.12\"\n",
)
.unwrap();
t
}
#[test]
fn a_first_run_creates_the_whole_harness() {
let p = project();
let w = materialise(p.path(), false).unwrap();
assert!(w.total() > 5, "expected a real harness, got {}", w.total());
assert_eq!(w.updated.len(), 0);
assert_eq!(w.unchanged.len(), 0);
assert!(p.path().join(DEST).join("session.py").is_file());
assert!(p.path().join(DEST).join(MANIFEST).is_file());
}
#[test]
fn a_second_run_changes_nothing() {
let p = project();
materialise(p.path(), false).unwrap();
let w = materialise(p.path(), false).unwrap();
assert_eq!(w.created.len(), 0);
assert_eq!(w.updated.len(), 0);
assert!(w.unchanged.len() > 5);
}
#[test]
fn a_local_edit_is_refused_not_overwritten() {
let p = project();
materialise(p.path(), false).unwrap();
let edited = p.path().join(DEST).join("session.py");
std::fs::write(&edited, b"# mine now\n").unwrap();
let err = materialise(p.path(), false).unwrap_err();
assert!(matches!(err, ProbeError::LocallyModified { count: 1, .. }));
assert_eq!(std::fs::read_to_string(&edited).unwrap(), "# mine now\n");
}
#[test]
fn force_overwrites_a_local_edit() {
let p = project();
materialise(p.path(), false).unwrap();
let edited = p.path().join(DEST).join("session.py");
std::fs::write(&edited, b"# mine now\n").unwrap();
let w = materialise(p.path(), true).unwrap();
assert!(w.updated.contains(&"session.py".to_string()));
assert_ne!(std::fs::read_to_string(&edited).unwrap(), "# mine now\n");
}
#[test]
fn a_consumers_own_file_under_the_tree_is_left_alone() {
let p = project();
materialise(p.path(), false).unwrap();
let theirs = p.path().join(DEST).join("my_notes.md");
std::fs::write(&theirs, b"notes").unwrap();
materialise(p.path(), false).unwrap();
assert!(theirs.is_file());
}
#[test]
fn provenance_round_trips_and_preserves_formatting() {
let p = project();
let before = std::fs::read_to_string(p.path().join("Cargo.toml")).unwrap();
record_provenance(p.path(), "0.12.1").unwrap();
assert_eq!(recorded_provenance(p.path()).as_deref(), Some("0.12.1"));
let after = std::fs::read_to_string(p.path().join("Cargo.toml")).unwrap();
assert!(after.contains("name = \"app\""));
assert!(after.contains("teksilo = \"0.12\""));
assert!(after.len() > before.len());
}
#[test]
fn provenance_is_idempotent() {
let p = project();
record_provenance(p.path(), "0.12.1").unwrap();
let once = std::fs::read_to_string(p.path().join("Cargo.toml")).unwrap();
record_provenance(p.path(), "0.12.1").unwrap();
let twice = std::fs::read_to_string(p.path().join("Cargo.toml")).unwrap();
assert_eq!(once, twice);
}
#[test]
fn provenance_updates_in_place_on_upgrade() {
let p = project();
record_provenance(p.path(), "0.12.0").unwrap();
record_provenance(p.path(), "0.12.1").unwrap();
assert_eq!(recorded_provenance(p.path()).as_deref(), Some("0.12.1"));
let text = std::fs::read_to_string(p.path().join("Cargo.toml")).unwrap();
assert_eq!(
text.matches("probe =").count(),
1,
"must update, not append"
);
}
#[test]
fn no_provenance_recorded_reads_as_none() {
let p = project();
assert_eq!(recorded_provenance(p.path()), None);
}
#[test]
fn the_embedded_harness_carries_the_real_modules() {
let names: Vec<String> = walk(&PROBE)
.iter()
.map(|f| shipped_path(f.path()))
.collect();
for required in [
"session.py",
"bridge.py",
"navigate.py",
"tools.py",
"report.py",
] {
assert!(
names.iter().any(|n| n == required),
"missing {required} in {names:?}"
);
}
assert!(
!names.iter().any(|n| n.starts_with("tests/")),
"the harness test-suite must not ship: {names:?}"
);
assert!(!names.iter().any(|n| n == "generate_tools.py"));
}
#[test]
fn digest_distinguishes_content() {
assert_eq!(digest(b"abc"), digest(b"abc"));
assert_ne!(digest(b"abc"), digest(b"abd"));
}
}