use std::fs::{self, File as FsFile, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::model::{Error, File, Result, ValidSource, validate_source};
const HEAD_FILE: &str = "HEAD";
const LOCK_FILE: &str = ".lock";
const GENERATIONS_DIRECTORY: &str = "generations";
const MIGRATION_FILE: &str = ".kcode-rust-libs-v2-migration";
const MIGRATION_MAGIC: &str = "kcode-rust-libs-v2-migration-v1";
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
pub(crate) struct Snapshot {
pub(crate) repository: PathBuf,
pub(crate) identity: String,
pub(crate) files: Vec<File>,
}
pub(crate) fn create(root: &Path, name: &str, source: &ValidSource) -> Result<Snapshot> {
let root = root_path(root, true)?;
let _root_lock = lock_file(&root.join(".kcode-rust-libs-v2.lock"))?;
let repository = root.join(name);
match fs::symlink_metadata(&repository) {
Ok(_) => {
return Err(Error::new(
"already_exists",
format!("managed library {name:?} already exists"),
));
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(Error::io("inspect library destination", &repository, error)),
}
let staging = unique_directory(&root, &format!(".{name}.create"))?;
let result = (|| {
FsFile::create(staging.join(LOCK_FILE))
.map_err(|error| Error::io("create repository lock file", &staging, error))?;
let generations = staging.join(GENERATIONS_DIRECTORY);
fs::create_dir(&generations)
.map_err(|error| Error::io("create generations directory", &generations, error))?;
let identity = unique_id("g");
let generation = generations.join(&identity);
fs::create_dir(&generation)
.map_err(|error| Error::io("create initial generation", &generation, error))?;
materialize_source(&generation, &source.files)?;
fs::write(staging.join(HEAD_FILE), format!("{identity}\n"))
.map_err(|error| Error::io("write initial repository head", &staging, error))?;
fs::rename(&staging, &repository)
.map_err(|error| Error::io("commit new managed library", &repository, error))?;
Ok(Snapshot {
repository: repository.clone(),
identity,
files: source.files.clone(),
})
})();
if result.is_err() {
let _ = fs::remove_dir_all(&staging);
}
result
}
pub(crate) fn open(root: &Path, name: &str) -> Result<Snapshot> {
let root = root_path(root, false)?;
let repository = prepare_repository(&root, name)?;
let _lock = lock_file(&repository.join(LOCK_FILE))?;
read_current(&repository, name)
}
pub(crate) fn docs(root: &Path, name: &str) -> Result<(String, String)> {
let root = root_path(root, false)?;
let repository = prepare_repository(&root, name)?;
let _lock = lock_file(&repository.join(LOCK_FILE))?;
let identity = read_head(&repository)?;
let generation = checked_generation(&repository, &identity)?;
let manifest = read_regular_utf8(&generation.join("Cargo.toml"))?;
let documentation = read_regular_utf8(&generation.join("Documentation.md"))?;
let source = validate_source(
&[
File {
path: "Cargo.toml".to_owned(),
contents: manifest,
},
File {
path: "Documentation.md".to_owned(),
contents: documentation.clone(),
},
],
name,
)?;
Ok((source.version, documentation))
}
pub(crate) fn replace(
repository: &Path,
expected_identity: &str,
source: &ValidSource,
) -> Result<Snapshot> {
let _lock = lock_file(&repository.join(LOCK_FILE))?;
let current = read_head(repository)?;
if current != expected_identity {
return Err(Error::new(
"stale_snapshot",
"the managed library changed; reopen before writing",
));
}
let identity = commit_locked(repository, ¤t, source, false)?;
Ok(Snapshot {
repository: repository.to_path_buf(),
identity,
files: source.files.clone(),
})
}
pub(crate) fn materialize_source(root: &Path, files: &[File]) -> Result<()> {
for file in files {
let destination = root.join(&file.path);
let parent = destination.parent().ok_or_else(|| {
Error::new(
"unsafe_path",
format!("source path has no parent: {:?}", file.path),
)
})?;
fs::create_dir_all(parent)
.map_err(|error| Error::io("create source parent", parent, error))?;
fs::write(&destination, file.contents.as_bytes())
.map_err(|error| Error::io("write source file", &destination, error))?;
}
Ok(())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RepositoryLayout {
Current,
Legacy,
Partial,
}
struct MigrationRecord {
identity: String,
staging_name: String,
}
fn prepare_repository(root: &Path, name: &str) -> Result<PathBuf> {
let repository = checked_repository(&root.join(name), name)?;
if repository_layout(&repository)? == RepositoryLayout::Current
&& !path_exists(&repository.join(MIGRATION_FILE))?
{
return Ok(repository);
}
let _root_lock = lock_file(&root.join(".kcode-rust-libs-v2.lock"))?;
let repository = checked_repository(&root.join(name), name)?;
if let Some(record) = read_migration_record(&repository, name)? {
recover_recorded_migration(root, &repository, &record)?;
}
match repository_layout(&repository)? {
RepositoryLayout::Current => Ok(repository),
RepositoryLayout::Legacy => {
migrate_legacy_repository(root, &repository, name)?;
if repository_layout(&repository)? != RepositoryLayout::Current {
return Err(Error::new(
"migration",
"legacy migration did not produce a current repository",
));
}
Ok(repository)
}
RepositoryLayout::Partial => Err(Error::new(
"invalid_repository",
"repository contains an ambiguous partial V2 layout",
)),
}
}
fn repository_layout(repository: &Path) -> Result<RepositoryLayout> {
let head = path_exists(&repository.join(HEAD_FILE))?;
let lock = path_exists(&repository.join(LOCK_FILE))?;
let generations = path_exists(&repository.join(GENERATIONS_DIRECTORY))?;
Ok(match (head, lock, generations) {
(true, true, true) => RepositoryLayout::Current,
(false, false, false) => RepositoryLayout::Legacy,
_ => RepositoryLayout::Partial,
})
}
fn migrate_legacy_repository(root: &Path, repository: &Path, name: &str) -> Result<()> {
let files = read_source(repository)?;
let source = validate_source(&files, name)?;
let staging = unique_directory(root, &format!(".{name}.migrate"))?;
let staging_name = staging
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| Error::new("migration", "migration staging name is not UTF-8"))?
.to_owned();
let identity = unique_id("g");
let result = (|| {
FsFile::create(staging.join(LOCK_FILE))
.map_err(|error| Error::io("create migration lock file", &staging, error))?;
let generations = staging.join(GENERATIONS_DIRECTORY);
fs::create_dir(&generations)
.map_err(|error| Error::io("create migration generations", &generations, error))?;
let generation = generations.join(&identity);
fs::create_dir(&generation)
.map_err(|error| Error::io("create migration generation", &generation, error))?;
materialize_source(&generation, &source.files)?;
let record = MigrationRecord {
identity: identity.clone(),
staging_name: staging_name.clone(),
};
write_migration_record(repository, name, &record)?;
fs::rename(
staging.join(GENERATIONS_DIRECTORY),
repository.join(GENERATIONS_DIRECTORY),
)
.map_err(|error| Error::io("install migrated generations", repository, error))?;
fs::rename(staging.join(LOCK_FILE), repository.join(LOCK_FILE))
.map_err(|error| Error::io("install migrated lock file", repository, error))?;
replace_head(repository, &identity)?;
remove_staging(root, &staging_name)?;
fs::remove_file(repository.join(MIGRATION_FILE)).map_err(|error| {
Error::io(
"remove completed migration marker",
repository.join(MIGRATION_FILE),
error,
)
})?;
Ok(())
})();
if let Err(error) = result {
let recovery = match read_migration_record(repository, name) {
Ok(Some(record)) => recover_recorded_migration(root, repository, &record),
Ok(None) => remove_staging(root, &staging_name),
Err(recovery_error) => Err(recovery_error),
};
return match recovery {
Ok(()) => Err(error),
Err(recovery_error) => Err(Error::new(
"migration",
format!("{error}; automatic recovery failed: {recovery_error}"),
)),
};
}
Ok(())
}
fn write_migration_record(repository: &Path, name: &str, record: &MigrationRecord) -> Result<()> {
let path = repository.join(MIGRATION_FILE);
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|error| Error::io("create migration marker", &path, error))?;
file.write_all(
format!(
"{MIGRATION_MAGIC}\n{name}\n{}\n{}\n",
record.identity, record.staging_name
)
.as_bytes(),
)
.map_err(|error| Error::io("write migration marker", &path, error))?;
file.sync_all()
.map_err(|error| Error::io("sync migration marker", &path, error))
}
fn read_migration_record(repository: &Path, name: &str) -> Result<Option<MigrationRecord>> {
let path = repository.join(MIGRATION_FILE);
if !path_exists(&path)? {
return Ok(None);
}
let contents = read_regular_utf8(&path)?;
let lines = contents.lines().collect::<Vec<_>>();
if lines.len() != 4 || lines[0] != MIGRATION_MAGIC || lines[1] != name {
return Err(Error::new(
"invalid_repository",
"repository migration marker is invalid",
));
}
let identity = lines[2];
let expected_prefix = format!(".{name}.migrate-");
let staging_name = lines[3];
if !valid_identity(identity)
|| !staging_name.starts_with(&expected_prefix)
|| staging_name.len() > 255
|| !staging_name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
{
return Err(Error::new(
"invalid_repository",
"repository migration marker contains invalid identity",
));
}
Ok(Some(MigrationRecord {
identity: identity.to_owned(),
staging_name: staging_name.to_owned(),
}))
}
fn recover_recorded_migration(
root: &Path,
repository: &Path,
record: &MigrationRecord,
) -> Result<()> {
if path_exists(&repository.join(HEAD_FILE))? {
let current = read_head(repository)?;
if current != record.identity {
return Err(Error::new(
"invalid_repository",
"migration marker does not match repository HEAD",
));
}
checked_generation(repository, &record.identity)?;
checked_regular_file(&repository.join(LOCK_FILE), "repository lock file")?;
remove_staging(root, &record.staging_name)?;
fs::remove_file(repository.join(MIGRATION_FILE)).map_err(|error| {
Error::io(
"remove recovered migration marker",
repository.join(MIGRATION_FILE),
error,
)
})?;
return Ok(());
}
let generations = repository.join(GENERATIONS_DIRECTORY);
if path_exists(&generations)? {
checked_directory(&generations, "partial migration generations")?;
let entries = fs::read_dir(&generations)
.map_err(|error| Error::io("read partial migration generations", &generations, error))?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|error| Error::io("read partial migration entry", &generations, error))?;
if entries.len() != 1 || entries[0].file_name() != record.identity.as_str() {
return Err(Error::new(
"invalid_repository",
"partial migration generations do not match the migration marker",
));
}
checked_directory(&entries[0].path(), "partial migration generation")?;
fs::remove_dir_all(&generations).map_err(|error| {
Error::io("remove partial migration generations", &generations, error)
})?;
}
let lock = repository.join(LOCK_FILE);
if path_exists(&lock)? {
checked_regular_file(&lock, "partial migration lock file")?;
fs::remove_file(&lock)
.map_err(|error| Error::io("remove partial migration lock file", &lock, error))?;
}
remove_head_temporaries(repository)?;
remove_staging(root, &record.staging_name)?;
fs::remove_file(repository.join(MIGRATION_FILE)).map_err(|error| {
Error::io(
"remove rolled-back migration marker",
repository.join(MIGRATION_FILE),
error,
)
})
}
fn remove_staging(root: &Path, staging_name: &str) -> Result<()> {
let staging = root.join(staging_name);
match fs::symlink_metadata(&staging) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => Err(Error::new(
"unsafe_source",
format!("migration staging path is unsafe: {}", staging.display()),
)),
Ok(_) => fs::remove_dir_all(&staging)
.map_err(|error| Error::io("remove migration staging", &staging, error)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(Error::io("inspect migration staging", &staging, error)),
}
}
fn remove_head_temporaries(repository: &Path) -> Result<()> {
let entries = fs::read_dir(repository)
.map_err(|error| Error::io("read repository during recovery", repository, error))?;
for entry in entries {
let entry = entry
.map_err(|error| Error::io("read repository recovery entry", repository, error))?;
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if !name.starts_with(".HEAD.") || !name.ends_with(".tmp") {
continue;
}
checked_regular_file(&entry.path(), "temporary repository head")?;
fs::remove_file(entry.path())
.map_err(|error| Error::io("remove temporary repository head", entry.path(), error))?;
}
Ok(())
}
fn read_current(repository: &Path, name: &str) -> Result<Snapshot> {
let identity = read_head(repository)?;
let generation = checked_generation(repository, &identity)?;
let files = read_source(&generation)?;
let source = validate_source(&files, name)?;
Ok(Snapshot {
repository: repository.to_path_buf(),
identity,
files: source.files,
})
}
fn commit_locked(
repository: &Path,
previous_identity: &str,
source: &ValidSource,
fail_before_head: bool,
) -> Result<String> {
let generations = repository.join(GENERATIONS_DIRECTORY);
checked_directory(&generations, "generations directory")?;
let identity = unique_id("g");
let staging = generations.join(format!(".{identity}.stage"));
fs::create_dir(&staging)
.map_err(|error| Error::io("create staged generation", &staging, error))?;
if let Err(error) = materialize_source(&staging, &source.files) {
let _ = fs::remove_dir_all(&staging);
return Err(error);
}
let generation = generations.join(&identity);
if let Err(error) = fs::rename(&staging, &generation) {
let _ = fs::remove_dir_all(&staging);
return Err(Error::io("finish staged generation", &generation, error));
}
if fail_before_head {
return Err(Error::new(
"injected_failure",
"staged generation was not made current",
));
}
if let Err(error) = replace_head(repository, &identity) {
let _ = fs::remove_dir_all(&generation);
return Err(error);
}
if previous_identity != identity {
let _ = fs::remove_dir_all(generations.join(previous_identity));
}
Ok(identity)
}
fn replace_head(repository: &Path, identity: &str) -> Result<()> {
let temporary = repository.join(format!(".HEAD.{}.tmp", unique_id("h")));
let result = (|| {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
.map_err(|error| Error::io("create temporary repository head", &temporary, error))?;
file.write_all(format!("{identity}\n").as_bytes())
.map_err(|error| Error::io("write temporary repository head", &temporary, error))?;
file.sync_all()
.map_err(|error| Error::io("sync temporary repository head", &temporary, error))?;
fs::rename(&temporary, repository.join(HEAD_FILE))
.map_err(|error| Error::io("replace repository head", repository, error))
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
fn root_path(path: &Path, create: bool) -> Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|error| Error::io("read current directory", ".", error))?
.join(path)
};
if create {
fs::create_dir_all(&absolute)
.map_err(|error| Error::io("create managed-library root", &absolute, error))?;
}
checked_directory(&absolute, "managed-library root")?;
fs::canonicalize(&absolute)
.map_err(|error| Error::io("canonicalize managed-library root", &absolute, error))
}
fn checked_repository(path: &Path, name: &str) -> Result<PathBuf> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(Error::new(
"unsafe_source",
format!("managed library {name:?} is a symlink"),
)),
Ok(metadata) if metadata.is_dir() => Ok(path.to_path_buf()),
Ok(_) => Err(Error::new(
"invalid_repository",
format!("managed library {name:?} is not a directory"),
)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::new(
"not_found",
format!("managed library {name:?} does not exist"),
)),
Err(error) => Err(Error::io("inspect managed library", path, error)),
}
}
fn checked_generation(repository: &Path, identity: &str) -> Result<PathBuf> {
let generation = repository.join(GENERATIONS_DIRECTORY).join(identity);
checked_directory(&generation, "repository generation")?;
Ok(generation)
}
fn checked_directory(path: &Path, label: &str) -> Result<()> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(Error::new(
"unsafe_source",
format!("{label} is a symlink: {}", path.display()),
)),
Ok(metadata) if metadata.is_dir() => Ok(()),
Ok(_) => Err(Error::new(
"invalid_repository",
format!("{label} is not a directory: {}", path.display()),
)),
Err(error) => Err(Error::io("inspect directory", path, error)),
}
}
fn checked_regular_file(path: &Path, label: &str) -> Result<()> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
Err(Error::new(
"unsafe_source",
format!("{label} is not a regular file: {}", path.display()),
))
}
Ok(_) => Ok(()),
Err(error) => Err(Error::io("inspect regular file", path, error)),
}
}
fn read_head(repository: &Path) -> Result<String> {
let head = read_regular_utf8(&repository.join(HEAD_FILE))?;
let identity = head.trim();
if !valid_identity(identity) {
return Err(Error::new(
"invalid_repository",
"repository HEAD contains an invalid generation identity",
));
}
Ok(identity.to_owned())
}
fn valid_identity(identity: &str) -> bool {
!identity.is_empty()
&& identity.len() <= 96
&& identity
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
}
fn read_source(root: &Path) -> Result<Vec<File>> {
let mut files = Vec::new();
walk_source(root, root, &mut files)?;
files.sort_by(|left, right| left.path.cmp(&right.path));
Ok(files)
}
fn walk_source(root: &Path, directory: &Path, files: &mut Vec<File>) -> Result<()> {
let mut entries = fs::read_dir(directory)
.map_err(|error| Error::io("read source directory", directory, error))?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|error| Error::io("read source entry", directory, error))?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|error| Error::io("inspect source entry", &path, error))?;
if metadata.file_type().is_symlink() {
return Err(Error::new(
"unsafe_source",
format!("source symlink is not allowed: {}", path.display()),
));
}
if metadata.is_dir() {
walk_source(root, &path, files)?;
continue;
}
if !metadata.is_file() {
return Err(Error::new(
"unsafe_source",
format!("special source entry is not allowed: {}", path.display()),
));
}
let relative = path
.strip_prefix(root)
.map_err(|_| Error::new("invalid_repository", "source entry escaped its generation"))?;
let relative = relative.to_str().ok_or_else(|| {
Error::new(
"unsafe_source",
format!("non-UTF-8 source path: {}", path.display()),
)
})?;
let relative = relative.replace(std::path::MAIN_SEPARATOR, "/");
if relative == "Cargo.lock" {
continue;
}
let bytes = fs::read(&path).map_err(|error| Error::io("read source file", &path, error))?;
let contents = String::from_utf8(bytes).map_err(|_| {
Error::new(
"unsafe_source",
format!("non-UTF-8 source file: {}", path.display()),
)
})?;
files.push(File {
path: relative,
contents,
});
}
Ok(())
}
fn read_regular_utf8(path: &Path) -> Result<String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| Error::io("inspect required source file", path, error))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(Error::new(
"unsafe_source",
format!("required source is not a regular file: {}", path.display()),
));
}
let bytes = fs::read(path).map_err(|error| Error::io("read source file", path, error))?;
String::from_utf8(bytes).map_err(|_| {
Error::new(
"unsafe_source",
format!("source file is not UTF-8: {}", path.display()),
)
})
}
fn path_exists(path: &Path) -> Result<bool> {
match fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(Error::io("inspect repository path", path, error)),
}
}
fn lock_file(path: &Path) -> Result<CallLock> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
return Err(Error::new(
"unsafe_source",
format!("lock path is not a regular file: {}", path.display()),
));
}
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(Error::io("inspect lock file", path, error)),
}
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.map_err(|error| Error::io("open lock file", path, error))?;
if !file
.metadata()
.map_err(|error| Error::io("inspect opened lock file", path, error))?
.is_file()
{
return Err(Error::new(
"unsafe_source",
format!("opened lock path is not a regular file: {}", path.display()),
));
}
FsFile::lock(&file).map_err(|error| Error::io("lock managed library", path, error))?;
Ok(CallLock(file))
}
struct CallLock(FsFile);
impl Drop for CallLock {
fn drop(&mut self) {
let _ = FsFile::unlock(&self.0);
}
}
fn unique_directory(parent: &Path, prefix: &str) -> Result<PathBuf> {
for _ in 0..100 {
let path = parent.join(format!("{prefix}-{}", unique_id("d")));
match fs::create_dir(&path) {
Ok(()) => return Ok(path),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(Error::io("create unique directory", path, error)),
}
}
Err(Error::new("io", "could not allocate a unique directory"))
}
fn unique_id(prefix: &str) -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let counter = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{prefix}-{:x}-{nanos:x}-{counter:x}", std::process::id())
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::model::{File, validate_source};
use super::{
GENERATIONS_DIRECTORY, LOCK_FILE, MIGRATION_FILE, MigrationRecord, commit_locked, create,
docs, open, read_head, write_migration_record,
};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
struct TestRoot(PathBuf);
impl TestRoot {
fn new(label: &str) -> Self {
let counter = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"kcode-rust-libs-v2-{label}-{}-{counter}",
std::process::id()
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestRoot {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn source(documentation: &str) -> crate::model::ValidSource {
validate_source(
&[
File {
path: "Cargo.toml".to_owned(),
contents:
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n"
.to_owned(),
},
File {
path: "Documentation.md".to_owned(),
contents: documentation.to_owned(),
},
File {
path: "src/lib.rs".to_owned(),
contents: String::new(),
},
],
"demo",
)
.unwrap()
}
fn legacy_repository(root: &Path, name: &str, documentation: &str) -> PathBuf {
let repository = root.join(name);
fs::create_dir(&repository).unwrap();
fs::create_dir(repository.join("src")).unwrap();
fs::write(
repository.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.4.2\"\nedition = \"2024\"\n"),
)
.unwrap();
fs::write(repository.join("Documentation.md"), documentation).unwrap();
fs::write(repository.join("src/lib.rs"), "pub fn legacy() {}\n").unwrap();
fs::write(repository.join("Cargo.lock"), "version = 4\n").unwrap();
repository
}
#[test]
fn a_staged_failure_does_not_advance_head() {
let root = TestRoot::new("staged-failure");
let snapshot = create(root.path(), "demo", &source("old")).unwrap();
let old_head = read_head(&snapshot.repository).unwrap();
let error =
commit_locked(&snapshot.repository, &old_head, &source("new"), true).unwrap_err();
assert!(error.to_string().starts_with("injected_failure:"));
assert_eq!(read_head(&snapshot.repository).unwrap(), old_head);
assert_eq!(open(root.path(), "demo").unwrap().files[1].contents, "old");
}
#[cfg(unix)]
#[test]
fn docs_does_not_traverse_unrelated_source_but_open_rejects_symlinks() {
use std::os::unix::fs::symlink;
let root = TestRoot::new("docs-only");
let snapshot = create(root.path(), "demo", &source("docs")).unwrap();
let identity = read_head(&snapshot.repository).unwrap();
let generation = snapshot.repository.join("generations").join(identity);
symlink(root.path(), generation.join("src/bad-link")).unwrap();
assert_eq!(
docs(root.path(), "demo").unwrap(),
("0.1.0".to_owned(), "docs".to_owned())
);
assert!(open(root.path(), "demo").is_err());
}
#[test]
fn open_omits_a_legacy_root_lockfile() {
let root = TestRoot::new("legacy-lock");
let snapshot = create(root.path(), "demo", &source("docs")).unwrap();
let identity = read_head(&snapshot.repository).unwrap();
fs::write(
snapshot
.repository
.join("generations")
.join(identity)
.join("Cargo.lock"),
"version = 4\n",
)
.unwrap();
assert!(
open(root.path(), "demo")
.unwrap()
.files
.iter()
.all(|file| file.path != "Cargo.lock")
);
}
#[test]
fn open_migrates_a_valid_flat_legacy_repository() {
let root = TestRoot::new("migrate-open");
let repository = legacy_repository(root.path(), "demo", "legacy docs\n");
let snapshot = open(root.path(), "demo").unwrap();
assert_eq!(
snapshot
.files
.iter()
.map(|file| file.path.as_str())
.collect::<Vec<_>>(),
["Cargo.toml", "Documentation.md", "src/lib.rs"]
);
assert_eq!(snapshot.files[1].contents, "legacy docs\n");
assert!(repository.join("HEAD").is_file());
assert!(repository.join(".lock").is_file());
assert!(repository.join("generations").is_dir());
assert!(repository.join("Cargo.toml").is_file());
assert_eq!(open(root.path(), "demo").unwrap().files, snapshot.files);
}
#[test]
fn docs_also_migrates_a_valid_flat_legacy_repository() {
let root = TestRoot::new("migrate-docs");
let repository = legacy_repository(root.path(), "demo", "legacy docs\n");
assert_eq!(
docs(root.path(), "demo").unwrap(),
("0.4.2".to_owned(), "legacy docs\n".to_owned())
);
assert!(repository.join("HEAD").is_file());
}
#[test]
fn invalid_or_partial_legacy_layouts_are_not_migrated() {
let root = TestRoot::new("migrate-reject");
let invalid = legacy_repository(root.path(), "wrong-name", "docs\n");
fs::write(
invalid.join("Cargo.toml"),
"[package]\nname = \"different\"\nversion = \"0.1.0\"\n",
)
.unwrap();
assert!(open(root.path(), "wrong-name").is_err());
assert!(!invalid.join("HEAD").exists());
assert!(!invalid.join(".lock").exists());
assert!(!invalid.join("generations").exists());
let partial = legacy_repository(root.path(), "partial", "docs\n");
fs::write(partial.join(".lock"), "").unwrap();
assert!(open(root.path(), "partial").is_err());
assert!(!partial.join("HEAD").exists());
assert!(!partial.join("generations").exists());
}
#[test]
fn recorded_partial_migration_is_rolled_back_before_retry() {
let root = TestRoot::new("migration-rollback");
let repository = legacy_repository(root.path(), "demo", "legacy docs\n");
let identity = "g-recovery".to_owned();
let staging_name = ".demo.migrate-recovery".to_owned();
let staging = root.path().join(&staging_name);
fs::create_dir(&staging).unwrap();
fs::write(staging.join(LOCK_FILE), "").unwrap();
let generations = staging.join(GENERATIONS_DIRECTORY);
fs::create_dir(&generations).unwrap();
let generation = generations.join(&identity);
fs::create_dir(&generation).unwrap();
let staged = source("uncommitted docs\n");
super::materialize_source(&generation, &staged.files).unwrap();
write_migration_record(
&repository,
"demo",
&MigrationRecord {
identity: identity.clone(),
staging_name: staging_name.clone(),
},
)
.unwrap();
fs::rename(
staging.join(GENERATIONS_DIRECTORY),
repository.join(GENERATIONS_DIRECTORY),
)
.unwrap();
fs::rename(staging.join(LOCK_FILE), repository.join(LOCK_FILE)).unwrap();
let snapshot = open(root.path(), "demo").unwrap();
assert_eq!(snapshot.files[1].contents, "legacy docs\n");
assert!(!root.path().join(staging_name).exists());
assert!(!repository.join(MIGRATION_FILE).exists());
}
#[test]
fn recorded_completed_migration_is_finalized() {
let root = TestRoot::new("migration-finalize");
let snapshot = create(root.path(), "demo", &source("current docs\n")).unwrap();
let staging_name = ".demo.migrate-finalize".to_owned();
fs::create_dir(root.path().join(&staging_name)).unwrap();
write_migration_record(
&snapshot.repository,
"demo",
&MigrationRecord {
identity: snapshot.identity.clone(),
staging_name: staging_name.clone(),
},
)
.unwrap();
assert_eq!(
docs(root.path(), "demo").unwrap(),
("0.1.0".to_owned(), "current docs\n".to_owned())
);
assert!(!root.path().join(staging_name).exists());
assert!(!snapshot.repository.join(MIGRATION_FILE).exists());
}
}