use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Component, Path, PathBuf};
use std::time::SystemTime;
use crate::config::{Config, GeneratedState, PythonGeneratedState, TypeScriptGeneratedState};
use crate::project::Project;
use anyhow::{Context, Result, bail};
use fs4::FileExt;
pub(super) struct ProjectLock(fs::File);
type SourceState = (BTreeMap<PathBuf, (u64, Option<SystemTime>)>, String);
pub(super) struct SourceWatcher {
root: PathBuf,
config: PathBuf,
state: SourceState,
}
impl SourceWatcher {
pub(super) fn new(root: &Path, config: &Path) -> Result<Self> {
Ok(Self {
root: root.to_path_buf(),
config: config.to_path_buf(),
state: source_state(root, config)?,
})
}
pub(super) fn changed(&mut self) -> Result<bool> {
let next = source_state(&self.root, &self.config)?;
let changed = next != self.state;
self.state = next;
Ok(changed)
}
}
pub(super) fn project_lock(project: &Project) -> Result<ProjectLock> {
let path = project.root.join(".rspyts-build.lock");
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.with_context(|| format!("failed to open {}", path.display()))?;
FileExt::lock(&file).with_context(|| format!("failed to lock {}", path.display()))?;
Ok(ProjectLock(file))
}
impl Drop for ProjectLock {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.0);
}
}
fn file_tree(root: &Path) -> Result<BTreeMap<PathBuf, Vec<u8>>> {
let mut result = BTreeMap::new();
collect_files(root, root, &mut result)?;
Ok(result)
}
pub(super) fn write_generated_gitignores(root: &Path) -> Result<()> {
let files = file_tree(root)?;
for source_root in ["src-py", "src-ts"] {
let mut paths = BTreeSet::new();
for path in files.keys() {
let Ok(relative) = path.strip_prefix(source_root) else {
continue;
};
if source_root == "src-py" && is_native_binary(path) {
let base = native_module_base(relative)
.context("Python native module has an unsupported filename")?;
let base = portable_path(&base)?;
paths.insert(format!("{base}.abi3.so"));
paths.insert(format!("{base}.pyd"));
} else {
paths.insert(portable_path(relative)?);
}
}
write(
&root.join(source_root).join(".gitignore"),
&generated_gitignore(&paths.into_iter().collect::<Vec<_>>()),
)?;
}
Ok(())
}
pub(super) fn generated_gitignore(paths: &[String]) -> String {
let mut paths = paths.iter().cloned().collect::<BTreeSet<_>>();
paths.insert(".gitignore".to_owned());
let mut source = String::from(
"# =============================================================================\n# AUTO-GENERATED BY rspyts - DO NOT EDIT.\n#\n# This file is overwritten by `rspyts build`.\n# =============================================================================\n\n",
);
for path in paths {
writeln!(source, "/{path}").expect("writing to a String cannot fail");
}
source
}
struct GeneratedPlan {
ownership: GeneratedState,
files: BTreeMap<PathBuf, Vec<u8>>,
}
impl GeneratedPlan {
fn read(root: &Path, source_fingerprint: &str) -> Result<Self> {
let files = file_tree(root)?;
let mut python_files = BTreeSet::new();
let mut typescript_files = BTreeSet::new();
let mut native_modules = BTreeSet::new();
for path in files.keys() {
if is_native_binary(path) {
if !matches!(path.components().next(), Some(Component::Normal(root)) if root == "src-py")
{
bail!(
"generated native module is outside src-py: {}",
path.display()
);
}
let base = native_module_base(path)
.context("Python native module has an unsupported filename")?;
let key = portable_path(&base)?;
native_modules.insert(key.clone());
continue;
}
let key = portable_path(path)?;
match path.components().next() {
Some(Component::Normal(root)) if root == "src-py" => {
python_files.insert(key);
}
Some(Component::Normal(root)) if root == "src-ts" => {
typescript_files.insert(key);
}
_ => bail!(
"generated file is outside src-py and src-ts: {}",
path.display()
),
}
}
Ok(Self {
ownership: GeneratedState {
source_fingerprint: source_fingerprint.to_owned(),
python: PythonGeneratedState {
files: python_files.into_iter().collect(),
native_modules: native_modules.into_iter().collect(),
},
typescript: TypeScriptGeneratedState {
files: typescript_files.into_iter().collect(),
},
},
files,
})
}
}
pub(super) fn reconcile_generated_files(
generated: &Path,
project_root: &Path,
source_fingerprint: &str,
config: &Config,
) -> Result<()> {
reconcile_generated_files_with(
generated,
project_root,
source_fingerprint,
config,
write_ownership,
)
}
fn reconcile_generated_files_with(
generated: &Path,
project_root: &Path,
source_fingerprint: &str,
config: &Config,
publish_ownership: impl FnOnce(&Config, &GeneratedState) -> Result<()>,
) -> Result<()> {
let plan = GeneratedPlan::read(generated, source_fingerprint)?;
let previous = &config.generated;
let previous_paths = ownership_paths(previous)?;
let expected_paths = plan.files.keys().cloned().collect::<BTreeSet<_>>();
let affected = previous_paths
.union(&expected_paths)
.cloned()
.collect::<BTreeSet<_>>();
for relative in &affected {
let destination = project_root.join(relative);
validate_parent_directories(project_root, relative)?;
match fs::symlink_metadata(&destination) {
Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {
if expected_paths.contains(relative) && !previous_paths.contains(relative) {
bail!(
"generated path collides with a user-owned file: {}",
destination.display()
);
}
}
Ok(_) => {
if expected_paths.contains(relative) && !previous_paths.contains(relative) {
bail!(
"generated path collides with a user-owned path: {}",
destination.display()
);
}
bail!(
"generated path is not a normal file: {}",
destination.display()
);
}
Err(error) if error.kind() == ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
let backup = tempfile::Builder::new()
.prefix(".rspyts-backup-")
.tempdir_in(project_root)?;
let mut backed_up = Vec::new();
for relative in &affected {
let source = project_root.join(relative);
match fs::symlink_metadata(&source) {
Ok(_) => {}
Err(error) if error.kind() == ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
}
let destination = backup.path().join(relative);
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&source, &destination)?;
backed_up.push(relative.clone());
}
let mut installed = Vec::new();
let mut created_directories = Vec::new();
let publish = (|| -> Result<()> {
for relative in &backed_up {
fs::remove_file(project_root.join(relative))?;
}
for relative in &expected_paths {
let source = generated.join(relative);
let destination = project_root.join(relative);
if let Some(parent) = destination.parent() {
create_directories(project_root, parent, &mut created_directories)?;
}
fs::rename(&source, &destination).with_context(|| {
format!("failed to publish generated file {}", destination.display())
})?;
installed.push(relative.clone());
}
cleanup_empty_directories(project_root, &affected)?;
publish_ownership(config, &plan.ownership)
})();
if let Err(error) = publish {
for relative in installed.iter().rev() {
let path = project_root.join(relative);
if path.exists() {
let _ = fs::remove_file(path);
}
}
for relative in backed_up.iter().rev() {
let source = backup.path().join(relative);
let destination = project_root.join(relative);
if let Some(parent) = destination.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::copy(source, destination);
}
for directory in created_directories.iter().rev() {
if directory.is_dir()
&& fs::read_dir(directory).is_ok_and(|mut entries| entries.next().is_none())
{
let _ = fs::remove_dir(directory);
}
}
return Err(error);
}
Ok(())
}
fn validate_parent_directories(root: &Path, relative: &Path) -> Result<()> {
let mut parent = root.to_path_buf();
if let Some(relative_parent) = relative.parent() {
for component in relative_parent.components() {
parent.push(component);
match fs::symlink_metadata(&parent) {
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
Ok(_) => {
bail!(
"generated path collides with a user-owned path: {}",
parent.display()
);
}
Err(error) if error.kind() == ErrorKind::NotFound => break,
Err(error) => return Err(error.into()),
}
}
}
Ok(())
}
fn create_directories(root: &Path, parent: &Path, created: &mut Vec<PathBuf>) -> Result<()> {
let relative = parent.strip_prefix(root)?;
let mut current = root.to_path_buf();
for component in relative.components() {
current.push(component);
match fs::create_dir(¤t) {
Ok(()) => created.push(current.clone()),
Err(error) if error.kind() == ErrorKind::AlreadyExists => {
let metadata = fs::symlink_metadata(¤t)?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
bail!(
"generated path has a non-directory parent: {}",
current.display()
);
}
}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
pub(super) fn check_generated_files(
generated: &Path,
project_root: &Path,
source_fingerprint: &str,
config: &Config,
) -> Result<()> {
let plan = GeneratedPlan::read(generated, source_fingerprint)?;
let actual_ownership = &config.generated;
let mut missing = Vec::new();
let mut changed = Vec::new();
let mut stale = Vec::new();
for (relative, expected) in &plan.files {
let actual = project_root.join(relative);
if !actual.is_file() {
missing.push(relative.clone());
} else if !is_native_binary(relative) && fs::read(&actual)? != *expected {
changed.push(relative.clone());
}
}
for relative in ownership_paths(actual_ownership)? {
if !plan.files.contains_key(&relative) && project_root.join(&relative).is_file() {
stale.push(relative);
}
}
if *actual_ownership != plan.ownership
|| !missing.is_empty()
|| !changed.is_empty()
|| !stale.is_empty()
{
bail!(
"generated sources are not in sync (missing: {missing:?}; changed: {changed:?}; stale: {stale:?}); run `rspyts build`"
);
}
Ok(())
}
fn write_ownership(config: &Config, ownership: &GeneratedState) -> Result<()> {
let mut temporary = tempfile::NamedTempFile::new_in(config.root())?;
temporary.write_all(config.render_generated(ownership)?.as_bytes())?;
temporary.flush()?;
temporary
.as_file()
.set_permissions(fs::metadata(&config.path)?.permissions())?;
temporary
.persist(&config.path)
.map_err(|error| error.error)?;
Ok(())
}
fn ownership_paths(ownership: &GeneratedState) -> Result<BTreeSet<PathBuf>> {
let mut paths = ownership
.python
.files
.iter()
.chain(&ownership.typescript.files)
.map(|path| checked_relative(path))
.collect::<Result<BTreeSet<_>>>()?;
for base in &ownership.python.native_modules {
let base = checked_relative(base)?;
for extension in ["abi3.so", "pyd"] {
let mut path = base.clone();
path.set_extension(extension);
paths.insert(path);
}
}
Ok(paths)
}
fn checked_relative(value: &str) -> Result<PathBuf> {
let path = PathBuf::from(value.replace('/', std::path::MAIN_SEPARATOR_STR));
if path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
bail!("invalid generated path in rspyts.toml: {value}");
}
Ok(path)
}
fn portable_path(path: &Path) -> Result<String> {
if path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
bail!("generated path is not relative: {}", path.display());
}
Ok(path
.components()
.map(|component| {
component
.as_os_str()
.to_str()
.context("generated path is not valid UTF-8")
})
.collect::<Result<Vec<_>>>()?
.join("/"))
}
fn is_native_binary(path: &Path) -> bool {
path.extension()
.is_some_and(|extension| matches!(extension.to_str(), Some("pyd" | "so")))
}
fn native_module_base(path: &Path) -> Option<PathBuf> {
let mut base = path.to_path_buf();
match path.extension()?.to_str()? {
"pyd" => {
base.set_extension("");
}
"so" => {
base.set_extension("");
if base.extension()?.to_str()? != "abi3" {
return None;
}
base.set_extension("");
}
_ => return None,
}
Some(base)
}
fn cleanup_empty_directories(project_root: &Path, paths: &BTreeSet<PathBuf>) -> Result<()> {
let mut directories = paths
.iter()
.filter_map(|path| path.parent().map(Path::to_path_buf))
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
directories.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
for relative in directories {
let path = project_root.join(relative);
if path == project_root || !path.is_dir() {
continue;
}
if fs::read_dir(&path)?.next().is_none() {
fs::remove_dir(path)?;
}
}
Ok(())
}
fn collect_files(
root: &Path,
current: &Path,
result: &mut BTreeMap<PathBuf, Vec<u8>>,
) -> Result<()> {
let mut entries = fs::read_dir(current)
.with_context(|| format!("failed to read {}", current.display()))?
.collect::<std::io::Result<Vec<_>>>()?;
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let path = entry.path();
let metadata = fs::symlink_metadata(&path)?;
if metadata.file_type().is_symlink() {
bail!("generated output contains a symlink: {}", path.display());
}
if metadata.is_dir() {
collect_files(root, &path, result)?;
} else if metadata.is_file() {
result.insert(path.strip_prefix(root)?.to_path_buf(), fs::read(path)?);
}
}
Ok(())
}
fn source_state(root: &Path, config_path: &Path) -> Result<SourceState> {
let mut result = BTreeMap::new();
for path in source_files(root)? {
let metadata = path.metadata()?;
result.insert(path, (metadata.len(), metadata.modified().ok()));
}
let application = Config::read(config_path)?.application_fingerprint_source()?;
Ok((result, application))
}
pub(super) fn source_fingerprint(root: &Path, config: &Config) -> Result<String> {
const OFFSET: u128 = 144_066_263_297_769_815_596_495_629_667_062_367_629;
const PRIME: u128 = 309_485_009_821_345_068_724_781_371;
let mut fingerprint = OFFSET;
for path in source_files(root)? {
let relative = path
.strip_prefix(root)?
.to_string_lossy()
.replace('\\', "/");
for byte in relative
.bytes()
.chain([0])
.chain(fs::read(path)?)
.chain([0])
{
fingerprint ^= u128::from(byte);
fingerprint = fingerprint.wrapping_mul(PRIME);
}
}
for byte in env!("CARGO_PKG_VERSION").bytes() {
fingerprint ^= u128::from(byte);
fingerprint = fingerprint.wrapping_mul(PRIME);
}
for byte in config.application_fingerprint_source()?.bytes() {
fingerprint ^= u128::from(byte);
fingerprint = fingerprint.wrapping_mul(PRIME);
}
Ok(format!("{fingerprint:032x}"))
}
fn source_files(root: &Path) -> Result<Vec<PathBuf>> {
let mut result = Vec::new();
collect_source_files(root, &mut result)?;
result.sort();
Ok(result)
}
fn collect_source_files(current: &Path, result: &mut Vec<PathBuf>) -> Result<()> {
let entries = match fs::read_dir(current) {
Ok(entries) => entries,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
let metadata = entry.metadata()?;
if metadata.is_dir() {
if ignored_source_directory(&path, &entry.file_name()) {
continue;
}
collect_source_files(&path, result)?;
} else if metadata.is_file() && is_source_file(&path) {
result.push(path);
}
}
Ok(())
}
fn ignored_source_directory(path: &Path, name: &std::ffi::OsStr) -> bool {
if name == "target"
&& (path.join("CACHEDIR.TAG").is_file() || path.join(".rustc_info.json").is_file())
{
return true;
}
matches!(
name.to_str(),
Some(
".git"
| ".mypy_cache"
| ".pytest_cache"
| ".ruff_cache"
| ".venv"
| ".vitest-attachments"
| "__pycache__"
| "node_modules"
)
)
}
fn is_source_file(path: &Path) -> bool {
path.extension().is_some_and(|value| value == "rs")
|| path
.file_name()
.is_some_and(|value| matches!(value.to_str(), Some("Cargo.toml" | "Cargo.lock")))
}
pub(super) fn write(path: &Path, source: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.with_context(|| format!("generated file collision at {}", path.display()))?;
file.write_all(source.as_bytes())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CONFIG_FILE, CONFIG_TEMPLATE};
fn config(root: &Path) -> Config {
let path = root.join(CONFIG_FILE);
if !path.exists() {
fs::write(&path, CONFIG_TEMPLATE).unwrap();
}
Config::read(&path).unwrap()
}
#[test]
fn generated_state_separates_python_typescript_and_native_files() {
let generated = tempfile::tempdir().unwrap();
write(&generated.path().join("src-py/pkg/api.py"), "python\n").unwrap();
write(
&generated.path().join("src-py/pkg/native/native.abi3.so"),
"native\n",
)
.unwrap();
write(&generated.path().join("src-ts/pkg/api.ts"), "typescript\n").unwrap();
write(
&generated
.path()
.join("src-ts/build/pkg/native/native_bg.wasm"),
"wasm\n",
)
.unwrap();
write_generated_gitignores(generated.path()).unwrap();
let plan = GeneratedPlan::read(generated.path(), "source").unwrap();
assert_eq!(
plan.ownership.python.files,
["src-py/.gitignore", "src-py/pkg/api.py"]
);
assert_eq!(
plan.ownership.python.native_modules,
["src-py/pkg/native/native"]
);
assert_eq!(
plan.ownership.typescript.files,
[
"src-ts/.gitignore",
"src-ts/build/pkg/native/native_bg.wasm",
"src-ts/pkg/api.ts"
]
);
let python = fs::read_to_string(generated.path().join("src-py/.gitignore")).unwrap();
assert!(python.starts_with("# =================================="));
assert!(python.contains("AUTO-GENERATED BY rspyts - DO NOT EDIT"));
assert!(python.contains("/.gitignore\n"));
assert!(python.contains("/pkg/api.py\n"));
assert!(python.contains("/pkg/native/native.abi3.so\n"));
assert!(python.contains("/pkg/native/native.pyd\n"));
let typescript = fs::read_to_string(generated.path().join("src-ts/.gitignore")).unwrap();
assert!(typescript.contains("/pkg/api.ts\n"));
assert!(typescript.contains("/build/pkg/native/native_bg.wasm\n"));
}
#[test]
fn generated_gitignores_are_removed_when_generation_is_disabled() {
let project = tempfile::tempdir().unwrap();
let enabled = tempfile::tempdir().unwrap();
write(&enabled.path().join("src-py/pkg/api.py"), "enabled\n").unwrap();
write(&enabled.path().join("src-ts/pkg/api.ts"), "enabled\n").unwrap();
write_generated_gitignores(enabled.path()).unwrap();
reconcile_generated_files(
enabled.path(),
project.path(),
"enabled",
&config(project.path()),
)
.unwrap();
assert!(project.path().join("src-py/.gitignore").is_file());
assert!(project.path().join("src-ts/.gitignore").is_file());
let disabled = tempfile::tempdir().unwrap();
write(&disabled.path().join("src-py/pkg/api.py"), "disabled\n").unwrap();
write(&disabled.path().join("src-ts/pkg/api.ts"), "disabled\n").unwrap();
reconcile_generated_files(
disabled.path(),
project.path(),
"disabled",
&config(project.path()),
)
.unwrap();
assert!(!project.path().join("src-py/.gitignore").exists());
assert!(!project.path().join("src-ts/.gitignore").exists());
}
#[test]
fn reconciliation_preserves_user_files_and_removes_stale_generated_files() {
let project = tempfile::tempdir().unwrap();
for (path, source) in [
("src-py/pyproject.toml", "python manifest\n"),
("src-py/pkg/__init__.py", "python entrypoint\n"),
("src-ts/package.json", "typescript manifest\n"),
("src-ts/tsconfig.json", "typescript config\n"),
("src-ts/pkg/index.ts", "typescript entrypoint\n"),
] {
write(&project.path().join(path), source).unwrap();
}
let first = tempfile::tempdir().unwrap();
write(&first.path().join("src-py/pkg/api.py"), "first\n").unwrap();
reconcile_generated_files(first.path(), project.path(), "one", &config(project.path()))
.unwrap();
write(&project.path().join("src-py/pkg/custom.py"), "user\n").unwrap();
let second = tempfile::tempdir().unwrap();
write(&second.path().join("src-py/pkg/models.py"), "second\n").unwrap();
reconcile_generated_files(
second.path(),
project.path(),
"two",
&config(project.path()),
)
.unwrap();
assert!(!project.path().join("src-py/pkg/api.py").exists());
assert_eq!(
fs::read_to_string(project.path().join("src-py/pkg/models.py")).unwrap(),
"second\n"
);
assert_eq!(
fs::read_to_string(project.path().join("src-py/pkg/custom.py")).unwrap(),
"user\n"
);
for (path, source) in [
("src-py/pyproject.toml", "python manifest\n"),
("src-py/pkg/__init__.py", "python entrypoint\n"),
("src-ts/package.json", "typescript manifest\n"),
("src-ts/tsconfig.json", "typescript config\n"),
("src-ts/pkg/index.ts", "typescript entrypoint\n"),
] {
assert_eq!(
fs::read_to_string(project.path().join(path)).unwrap(),
source
);
}
let expected = tempfile::tempdir().unwrap();
write(&expected.path().join("src-py/pkg/models.py"), "second\n").unwrap();
check_generated_files(
expected.path(),
project.path(),
"two",
&config(project.path()),
)
.unwrap();
let ownership = fs::read_to_string(project.path().join(CONFIG_FILE)).unwrap();
let ownership: toml::Value = ownership.parse().unwrap();
assert_eq!(
ownership["generated"]["source_fingerprint"].as_str(),
Some("two")
);
assert_eq!(
ownership["generated"]["python"]["files"]
.as_array()
.and_then(|files| files[0].as_str()),
Some("src-py/pkg/models.py")
);
}
#[test]
fn reconciliation_rejects_user_owned_collisions_before_mutating() {
let project = tempfile::tempdir().unwrap();
write(&project.path().join("src-ts/pkg/api.ts"), "user\n").unwrap();
let generated = tempfile::tempdir().unwrap();
write(&generated.path().join("src-ts/pkg/api.ts"), "generated\n").unwrap();
let config = config(project.path());
let before = fs::read(project.path().join(CONFIG_FILE)).unwrap();
let error = reconcile_generated_files(generated.path(), project.path(), "one", &config)
.unwrap_err()
.to_string();
assert!(error.contains("user-owned file"));
assert_eq!(
fs::read_to_string(project.path().join("src-ts/pkg/api.ts")).unwrap(),
"user\n"
);
assert_eq!(fs::read(project.path().join(CONFIG_FILE)).unwrap(), before);
}
#[test]
fn reconciliation_rejects_user_owned_parent_collisions_before_mutating() {
let project = tempfile::tempdir().unwrap();
write(&project.path().join("src-ts"), "user\n").unwrap();
let generated = tempfile::tempdir().unwrap();
write(&generated.path().join("src-ts/pkg/api.ts"), "generated\n").unwrap();
let config = config(project.path());
let before = fs::read(project.path().join(CONFIG_FILE)).unwrap();
let error = reconcile_generated_files(generated.path(), project.path(), "one", &config)
.unwrap_err()
.to_string();
assert!(error.contains("user-owned path"));
assert_eq!(
fs::read_to_string(project.path().join("src-ts")).unwrap(),
"user\n"
);
assert_eq!(fs::read(project.path().join(CONFIG_FILE)).unwrap(), before);
}
#[test]
fn reconciliation_rolls_back_files_and_created_directories() {
let project = tempfile::tempdir().unwrap();
let first = tempfile::tempdir().unwrap();
write(&first.path().join("src-py/pkg/api.py"), "first\n").unwrap();
reconcile_generated_files(first.path(), project.path(), "one", &config(project.path()))
.unwrap();
let ownership = fs::read(project.path().join(CONFIG_FILE)).unwrap();
let second = tempfile::tempdir().unwrap();
write(&second.path().join("src-py/pkg/api.py"), "second\n").unwrap();
write(&second.path().join("src-ts/pkg/api.ts"), "new\n").unwrap();
let error = reconcile_generated_files_with(
second.path(),
project.path(),
"two",
&config(project.path()),
|_, _| bail!("forced publication failure"),
)
.unwrap_err()
.to_string();
assert!(error.contains("forced publication failure"));
assert_eq!(
fs::read_to_string(project.path().join("src-py/pkg/api.py")).unwrap(),
"first\n"
);
assert_eq!(
fs::read(project.path().join(CONFIG_FILE)).unwrap(),
ownership
);
assert!(!project.path().join("src-ts").exists());
}
#[test]
fn check_detects_generated_changes_but_ignores_user_files() {
let project = tempfile::tempdir().unwrap();
let generated = tempfile::tempdir().unwrap();
write(&generated.path().join("src-py/pkg/api.py"), "generated\n").unwrap();
reconcile_generated_files(
generated.path(),
project.path(),
"one",
&config(project.path()),
)
.unwrap();
write(&project.path().join("src-py/pkg/custom.py"), "user\n").unwrap();
let expected = tempfile::tempdir().unwrap();
write(&expected.path().join("src-py/pkg/api.py"), "generated\n").unwrap();
check_generated_files(
expected.path(),
project.path(),
"one",
&config(project.path()),
)
.unwrap();
fs::write(project.path().join("src-py/pkg/api.py"), "changed\n").unwrap();
let error = check_generated_files(
expected.path(),
project.path(),
"one",
&config(project.path()),
)
.unwrap_err()
.to_string();
assert!(error.contains("changed"));
}
#[test]
fn check_detects_missing_and_stale_generated_files() {
let project = tempfile::tempdir().unwrap();
let generated = tempfile::tempdir().unwrap();
write(&generated.path().join("src-py/pkg/api.py"), "generated\n").unwrap();
write(&generated.path().join("src-py/pkg/stale.py"), "stale\n").unwrap();
reconcile_generated_files(
generated.path(),
project.path(),
"one",
&config(project.path()),
)
.unwrap();
let expected = tempfile::tempdir().unwrap();
write(&expected.path().join("src-py/pkg/api.py"), "generated\n").unwrap();
let stale = check_generated_files(
expected.path(),
project.path(),
"one",
&config(project.path()),
)
.unwrap_err()
.to_string();
assert!(stale.contains("not in sync"));
let generated = tempfile::tempdir().unwrap();
write(&generated.path().join("src-py/pkg/api.py"), "generated\n").unwrap();
reconcile_generated_files(
generated.path(),
project.path(),
"one",
&config(project.path()),
)
.unwrap();
fs::remove_file(project.path().join("src-py/pkg/api.py")).unwrap();
let missing = check_generated_files(
expected.path(),
project.path(),
"one",
&config(project.path()),
)
.unwrap_err()
.to_string();
assert!(missing.contains("missing"));
}
}