use semver::{Version, VersionReq};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as FmtWrite;
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use tess::{PackageDependency, PackageDependencyGraph};
pub const MANIFEST_NAME: &str = "tess.toml";
pub const LOCKFILE_NAME: &str = "tess.lock";
pub const LOCKFILE_FORMAT_VERSION: u32 = 1;
#[derive(Clone, Debug)]
pub struct ProjectTarget {
pub entry: PathBuf,
pub root: PathBuf,
pub dependencies: Vec<PackageDependencyGraph>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Manifest {
name: String,
version: Version,
entry: PathBuf,
exports: BTreeSet<String>,
dependencies: BTreeMap<String, ManifestDependency>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct ManifestDependency {
path: Option<PathBuf>,
version: Option<VersionReq>,
symbols: Option<BTreeSet<String>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct LockDependency {
alias: String,
requirement: String,
manifest: String,
symbols: BTreeSet<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct LockPackage {
name: String,
version: Version,
manifest: String,
dependencies: Vec<LockDependency>,
}
struct PackageResolver {
workspace_root: PathBuf,
stack: Vec<PathBuf>,
packages: BTreeMap<PathBuf, LockPackage>,
}
#[derive(Clone, Copy)]
enum LockfileMode {
Strict,
Update,
}
pub fn resolve_target(target: &Path) -> Result<ProjectTarget, String> {
if target.is_dir() {
return target_from_manifest(&target.join(MANIFEST_NAME), LockfileMode::Strict);
}
if target.file_name().is_some_and(|name| name == MANIFEST_NAME) {
return target_from_manifest(target, LockfileMode::Strict);
}
if target.extension().and_then(|extension| extension.to_str()) != Some("tes") {
return Err(format!(
"`{}` is not a `.tes` entry file, project directory, or `{MANIFEST_NAME}`",
target.display()
));
}
if !target.is_file() {
return Err(format!(
"could not read `{}`: file not found",
target.display()
));
}
let parent = non_empty_parent(target);
if let Some(manifest) = find_manifest(parent) {
let mut resolved = target_from_manifest(&manifest, LockfileMode::Strict)?;
resolved.entry = target.to_path_buf();
Ok(resolved)
} else {
Ok(ProjectTarget {
entry: target.to_path_buf(),
root: parent.to_path_buf(),
dependencies: Vec::new(),
})
}
}
pub fn resolve_document_target(document: &Path) -> Result<ProjectTarget, String> {
let parent = non_empty_parent(document);
if let Some(manifest) = find_manifest(parent) {
target_from_manifest(&manifest, LockfileMode::Strict)
} else {
resolve_target(document)
}
}
pub fn update_lockfile(target: &Path) -> Result<PathBuf, String> {
let manifest = if target.is_dir() {
target.join(MANIFEST_NAME)
} else if target.file_name().is_some_and(|name| name == MANIFEST_NAME) {
target.to_path_buf()
} else if target.extension().and_then(|extension| extension.to_str()) == Some("tes") {
find_manifest(non_empty_parent(target)).ok_or_else(|| {
format!(
"`{}` is not owned by a `{MANIFEST_NAME}` project",
target.display()
)
})?
} else {
return Err(format!(
"`{}` is not a project directory, `{MANIFEST_NAME}`, or owned `.tes` file",
target.display()
));
};
let resolved = target_from_manifest(&manifest, LockfileMode::Update)?;
Ok(resolved.root.join(LOCKFILE_NAME))
}
fn target_from_manifest(path: &Path, lockfile_mode: LockfileMode) -> Result<ProjectTarget, String> {
let text = fs::read_to_string(path)
.map_err(|error| format!("could not read `{}`: {error}", path.display()))?;
let manifest = parse_manifest(&text)
.map_err(|message| format!("invalid `{}`: {message}", path.display()))?;
let root = non_empty_parent(path).to_path_buf();
if manifest.entry.is_absolute() {
return Err(format!(
"invalid `{}`: project entry must be relative to the manifest",
path.display()
));
}
if manifest.entry.extension().and_then(|value| value.to_str()) != Some("tes") {
return Err(format!(
"invalid `{}`: project entry must end in `.tes`",
path.display()
));
}
let entry = root.join(&manifest.entry);
let (dependencies, lockfile) = resolve_dependencies(path, &root, &manifest)?;
let lock_path = root.join(LOCKFILE_NAME);
match lockfile_mode {
LockfileMode::Strict if !dependencies.is_empty() || lock_path.exists() => {
ensure_lockfile(&lock_path, &lockfile)?;
}
LockfileMode::Update => write_lockfile(&lock_path, &lockfile)?,
LockfileMode::Strict => {}
}
Ok(ProjectTarget {
entry,
root,
dependencies,
})
}
fn resolve_dependencies(
manifest_path: &Path,
root: &Path,
manifest: &Manifest,
) -> Result<(Vec<PackageDependencyGraph>, String), String> {
let workspace_root = fs::canonicalize(root).map_err(|error| {
format!(
"could not resolve project root `{}`: {error}",
root.display()
)
})?;
let root_manifest = fs::canonicalize(manifest_path).map_err(|error| {
format!(
"could not resolve project manifest `{}`: {error}",
manifest_path.display()
)
})?;
let mut resolver = PackageResolver {
workspace_root,
stack: vec![root_manifest.clone()],
packages: BTreeMap::new(),
};
let dependencies = resolver.resolve_manifest(&root_manifest, root, manifest)?;
let lockfile = render_lockfile(resolver.packages.values());
Ok((dependencies, lockfile))
}
impl PackageResolver {
fn resolve_manifest(
&mut self,
manifest_path: &Path,
root: &Path,
manifest: &Manifest,
) -> Result<Vec<PackageDependencyGraph>, String> {
let mut resolved = Vec::new();
let mut lock_dependencies = Vec::new();
let mut aliases = BTreeSet::new();
for (alias, dependency) in &manifest.dependencies {
let normalized_alias = tess::compiler::normalize_name(alias);
if !aliases.insert(normalized_alias.clone()) {
return Err(format!(
"duplicate dependency alias `{normalized_alias}` in package `{}`",
manifest.name
));
}
let (graph, lock_dependency) = self.resolve_dependency(root, alias, dependency)?;
resolved.push(graph);
lock_dependencies.push(lock_dependency);
}
let manifest_name = relative_lock_path(&self.workspace_root, manifest_path)?;
let package = LockPackage {
name: manifest.name.clone(),
version: manifest.version.clone(),
manifest: manifest_name,
dependencies: lock_dependencies,
};
match self
.packages
.insert(manifest_path.to_path_buf(), package.clone())
{
Some(previous) if previous != package => Err(format!(
"local package `{}` resolved inconsistently through the dependency graph",
manifest_path.display()
)),
_ => Ok(resolved),
}
}
fn resolve_dependency(
&mut self,
root: &Path,
alias: &str,
dependency: &ManifestDependency,
) -> Result<(PackageDependencyGraph, LockDependency), String> {
if !valid_module_name(alias) {
return Err(format!(
"dependency alias `{alias}` is not a valid identifier"
));
}
let path = dependency
.path
.as_ref()
.ok_or_else(|| format!("dependency `{alias}` is missing required `path`"))?;
if path.is_absolute() {
return Err(format!("dependency `{alias}` path must be relative"));
}
let requested = root.join(path);
let manifest_path = if requested.is_dir() {
requested.join(MANIFEST_NAME)
} else {
requested
};
let text = fs::read_to_string(&manifest_path).map_err(|error| {
format!(
"could not read dependency `{alias}` manifest `{}`: {error}",
manifest_path.display()
)
})?;
let package = parse_manifest(&text).map_err(|message| {
format!(
"invalid dependency `{alias}` manifest `{}`: {message}",
manifest_path.display()
)
})?;
let manifest_path = fs::canonicalize(&manifest_path).map_err(|error| {
format!(
"could not resolve dependency `{alias}` manifest `{}`: {error}",
manifest_path.display()
)
})?;
let package_root = non_empty_parent(&manifest_path).to_path_buf();
if package.entry.is_absolute()
|| package.entry.extension().and_then(|value| value.to_str()) != Some("tes")
{
return Err(format!(
"dependency `{alias}` project entry must be a relative `.tes` path"
));
}
if let Some(requirement) = &dependency.version {
if !requirement.matches(&package.version) {
return Err(format!(
"dependency `{alias}` requires version `{requirement}`, but local package `{}` has version `{}`",
package.name, package.version
));
}
}
let exports = dependency
.symbols
.clone()
.unwrap_or_else(|| package.exports.clone());
let package_exports = package
.exports
.iter()
.map(|symbol| tess::compiler::normalize_name(symbol))
.collect::<BTreeSet<_>>();
if let Some(symbol) = exports
.iter()
.find(|symbol| !package_exports.contains(&tess::compiler::normalize_name(symbol)))
{
return Err(format!(
"dependency `{alias}` does not export requested symbol `{symbol}`"
));
}
if let Some(cycle_start) = self.stack.iter().position(|path| path == &manifest_path) {
let mut cycle = self.stack[cycle_start..]
.iter()
.map(|path| relative_lock_path(&self.workspace_root, path))
.collect::<Result<Vec<_>, _>>()?;
cycle.push(relative_lock_path(&self.workspace_root, &manifest_path)?);
return Err(format!(
"local package dependency cycle: {}",
cycle.join(" -> ")
));
}
self.stack.push(manifest_path.clone());
let children = self.resolve_manifest(&manifest_path, &package_root, &package)?;
self.stack.pop();
let locked_manifest = relative_lock_path(&self.workspace_root, &manifest_path)?;
let lock_dependency = LockDependency {
alias: alias.to_owned(),
requirement: dependency
.version
.as_ref()
.map_or_else(|| "*".to_owned(), ToString::to_string),
manifest: locked_manifest,
symbols: exports.clone(),
};
Ok((
PackageDependencyGraph {
dependency: PackageDependency {
alias: alias.to_owned(),
entry: package_root.join(&package.entry),
root: package_root,
exports,
},
dependencies: children,
},
lock_dependency,
))
}
}
fn relative_lock_path(base: &Path, target: &Path) -> Result<String, String> {
let base_components = base.components().collect::<Vec<_>>();
let target_components = target.components().collect::<Vec<_>>();
let mut common = 0usize;
while common < base_components.len()
&& common < target_components.len()
&& base_components[common] == target_components[common]
{
common += 1;
}
if common == 0 {
return Err(format!(
"local package `{}` cannot be represented relative to project root `{}`",
target.display(),
base.display()
));
}
let mut parts = Vec::new();
for component in &base_components[common..] {
if matches!(component, std::path::Component::Normal(_)) {
parts.push("..".to_owned());
}
}
for component in &target_components[common..] {
match component {
std::path::Component::Normal(value) => parts.push(
value
.to_str()
.ok_or_else(|| {
format!(
"local package path `{}` is not valid UTF-8 and cannot be locked",
target.display()
)
})?
.to_owned(),
),
std::path::Component::ParentDir => parts.push("..".to_owned()),
std::path::Component::CurDir => {}
std::path::Component::Prefix(_) | std::path::Component::RootDir => {
return Err(format!(
"local package `{}` cannot be represented relative to project root `{}`",
target.display(),
base.display()
));
}
}
}
Ok(if parts.is_empty() {
".".to_owned()
} else {
parts.join("/")
})
}
fn render_lockfile<'a>(packages: impl Iterator<Item = &'a LockPackage>) -> String {
let mut packages = packages.collect::<Vec<_>>();
packages.sort_by(|left, right| left.manifest.cmp(&right.manifest));
let mut output = String::from(
"# Generated by Tess. Commit this file; do not edit it by hand.\n\
# Only deterministic local path packages are represented.\n",
);
let _ = writeln!(output, "lock-version = {LOCKFILE_FORMAT_VERSION}");
for package in packages {
let _ = write!(
output,
"\n[[package]]\nname = \"{}\"\nversion = \"{}\"\nmanifest = \"{}\"\n",
escape_basic_string(&package.name),
package.version,
escape_basic_string(&package.manifest)
);
if package.dependencies.is_empty() {
output.push_str("dependencies = []\n");
continue;
}
output.push_str("dependencies = [\n");
for dependency in &package.dependencies {
let symbols = dependency
.symbols
.iter()
.map(|symbol| format!("\"{}\"", escape_basic_string(symbol)))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(
output,
" {{ alias = \"{}\", requirement = \"{}\", manifest = \"{}\", symbols = [{symbols}] }},",
escape_basic_string(&dependency.alias),
escape_basic_string(&dependency.requirement),
escape_basic_string(&dependency.manifest),
);
}
output.push_str("]\n");
}
output
}
fn ensure_lockfile(path: &Path, expected: &str) -> Result<(), String> {
let actual = fs::read_to_string(path).map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
format!(
"`{}` is missing for the local package graph; run `tess lock {}` and commit the result",
path.display(),
path.parent().unwrap_or_else(|| Path::new(".")).display()
)
} else {
format!("could not read `{}`: {error}", path.display())
}
})?;
if actual == expected {
Ok(())
} else {
Err(format!(
"`{}` is out of date for the local package graph; run `tess lock {}` and review the diff",
path.display(),
path.parent().unwrap_or_else(|| Path::new(".")).display()
))
}
}
fn write_lockfile(path: &Path, contents: &str) -> Result<(), String> {
crate::atomic_replace(path, contents.as_bytes())
.map_err(|error| format!("could not write `{}`: {error}", path.display()))
}
fn non_empty_parent(path: &Path) -> &Path {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
fn find_manifest(start: &Path) -> Option<PathBuf> {
let mut current = Some(start);
while let Some(directory) = current {
let candidate = directory.join(MANIFEST_NAME);
if candidate.is_file() {
return Some(candidate);
}
current = directory.parent();
}
None
}
fn parse_manifest(text: &str) -> Result<Manifest, String> {
#[derive(Clone)]
enum Section {
Project,
Exports,
Dependency(String),
}
let mut section = None;
let mut seen_sections = BTreeSet::new();
let mut name = None;
let mut version = None;
let mut entry = None;
let mut exports = None;
let mut dependencies = BTreeMap::<String, ManifestDependency>::new();
for (index, raw_line) in text.lines().enumerate() {
let line_number = index + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('[') {
let header = line
.strip_prefix('[')
.and_then(|line| line.strip_suffix(']'))
.ok_or_else(|| format!("line {line_number}: malformed table header"))?;
if !seen_sections.insert(header.to_owned()) {
return Err(format!("line {line_number}: duplicate table `[{header}]`"));
}
section = Some(match header {
"project" => Section::Project,
"exports" => Section::Exports,
value if value.starts_with("dependencies.") => {
let alias = value.trim_start_matches("dependencies.");
if alias.is_empty() {
return Err(format!(
"line {line_number}: dependency table requires an alias"
));
}
dependencies.entry(alias.to_owned()).or_default();
Section::Dependency(alias.to_owned())
}
_ => {
return Err(format!(
"line {line_number}: unsupported table `[{header}]`"
));
}
});
continue;
}
let Some(section) = §ion else {
return Err(format!(
"line {line_number}: properties must appear below a table header"
));
};
let Some((key, value)) = line.split_once('=') else {
return Err(format!("line {line_number}: expected `key = value`"));
};
let key = key.trim();
let value = value.trim();
match section {
Section::Project => {
let value = parse_basic_string(value).ok_or_else(|| {
format!("line {line_number}: `{key}` must be a quoted one-line string")
})?;
match key {
"name" if name.is_none() => name = Some(value),
"version" if version.is_none() => {
version = Some(Version::parse(&value).map_err(|error| {
format!(
"line {line_number}: invalid project version `{value}`: {error}"
)
})?);
}
"entry" if entry.is_none() => entry = Some(PathBuf::from(value)),
"name" | "version" | "entry" => {
return Err(format!("line {line_number}: duplicate `{key}` property"));
}
_ => {
return Err(format!(
"line {line_number}: unknown `[project]` property `{key}`"
));
}
}
}
Section::Exports => {
if key != "symbols" {
return Err(format!(
"line {line_number}: unknown `[exports]` property `{key}`"
));
}
if exports.is_some() {
return Err(format!("line {line_number}: duplicate `symbols` property"));
}
exports = Some(parse_string_array(value).ok_or_else(|| {
format!("line {line_number}: `symbols` must be an array of quoted strings")
})?);
}
Section::Dependency(alias) => {
let dependency = dependencies
.get_mut(alias)
.expect("dependency section initializes its entry");
match key {
"path" if dependency.path.is_none() => {
let path = parse_basic_string(value).ok_or_else(|| {
format!("line {line_number}: dependency `path` must be a quoted string")
})?;
dependency.path = Some(PathBuf::from(path));
}
"version" if dependency.version.is_none() => {
let requirement = parse_basic_string(value).ok_or_else(|| {
format!(
"line {line_number}: dependency `version` must be a quoted string"
)
})?;
dependency.version = Some(VersionReq::parse(&requirement).map_err(
|error| {
format!(
"line {line_number}: invalid dependency version requirement `{requirement}`: {error}"
)
},
)?);
}
"symbols" if dependency.symbols.is_none() => {
dependency.symbols = Some(parse_string_array(value).ok_or_else(|| {
format!(
"line {line_number}: dependency `symbols` must be an array of quoted strings"
)
})?);
}
"path" | "version" | "symbols" => {
return Err(format!("line {line_number}: duplicate `{key}` property"));
}
"registry" | "url" | "git" => {
return Err(format!(
"line {line_number}: remote dependency property `{key}` is not supported; use a relative local `path`"
));
}
_ => {
return Err(format!(
"line {line_number}: unknown dependency property `{key}`"
));
}
}
}
}
}
let name = name.ok_or_else(|| "missing required `name` property".to_owned())?;
if name.trim().is_empty() {
return Err("project `name` cannot be empty".to_owned());
}
let entry = entry.ok_or_else(|| "missing required `entry` property".to_owned())?;
Ok(Manifest {
name,
version: version.unwrap_or_else(|| Version::new(0, 0, 0)),
entry,
exports: exports.unwrap_or_default(),
dependencies,
})
}
fn parse_string_array(value: &str) -> Option<BTreeSet<String>> {
let inner = value.strip_prefix('[')?.strip_suffix(']')?.trim();
if inner.is_empty() {
return Some(BTreeSet::new());
}
let bytes = inner.as_bytes();
let mut cursor = 0usize;
let mut values = BTreeSet::new();
while cursor < bytes.len() {
while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
cursor += 1;
}
if bytes.get(cursor) != Some(&b'"') {
return None;
}
let start = cursor;
cursor += 1;
let mut escaped = false;
while cursor < bytes.len() {
let byte = bytes[cursor];
cursor += 1;
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
break;
}
}
if bytes.get(cursor.saturating_sub(1)) != Some(&b'"') {
return None;
}
let item = parse_basic_string(&inner[start..cursor])?;
if !values.insert(item) {
return None;
}
while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
cursor += 1;
}
if cursor == bytes.len() {
break;
}
if bytes.get(cursor) != Some(&b',') {
return None;
}
cursor += 1;
}
Some(values)
}
fn parse_basic_string(value: &str) -> Option<String> {
let inner = value.strip_prefix('"')?.strip_suffix('"')?;
let mut result = String::new();
let mut chars = inner.chars();
while let Some(character) = chars.next() {
if character != '\\' {
result.push(character);
continue;
}
match chars.next()? {
'\\' => result.push('\\'),
'"' => result.push('"'),
'n' => result.push('\n'),
'r' => result.push('\r'),
't' => result.push('\t'),
_ => return None,
}
}
Some(result)
}
pub fn scaffold(directory: &Path, requested_module: Option<&str>) -> Result<String, String> {
if directory.exists() {
let mut entries = fs::read_dir(directory)
.map_err(|error| format!("could not inspect `{}`: {error}", directory.display()))?;
if entries
.next()
.transpose()
.map_err(|error| error.to_string())?
.is_some()
{
return Err(format!(
"refusing to initialize non-empty directory `{}`",
directory.display()
));
}
}
let project_name = directory
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or("tess-project");
let module = requested_module.map_or_else(|| module_name(project_name), str::to_owned);
if !valid_module_name(&module) {
return Err(format!(
"`{module}` is not a valid Tess module identifier; pass letters, numbers, and `_`, starting with a letter or `_`"
));
}
let files = scaffold_files(project_name, &module);
for (relative, _) in &files {
let path = directory.join(relative);
if path.exists() {
return Err(format!("refusing to overwrite `{}`", path.display()));
}
}
fs::create_dir_all(directory.join("src/model"))
.and_then(|()| fs::create_dir_all(directory.join("src/rules")))
.and_then(|()| fs::create_dir_all(directory.join("tests")))
.map_err(|error| format!("could not create project directories: {error}"))?;
for (relative, contents) in files {
write_new(&directory.join(relative), &contents)
.map_err(|error| format!("could not create project file: {error}"))?;
}
Ok(module)
}
fn write_new(path: &Path, contents: &str) -> io::Result<()> {
let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
file.write_all(contents.as_bytes())
}
fn module_name(project_name: &str) -> String {
let mut result = String::new();
for character in project_name.chars() {
if character.is_alphanumeric() || character == '_' {
result.push(character.to_ascii_lowercase());
} else if !result.ends_with('_') {
result.push('_');
}
}
if result.is_empty() || result.chars().next().is_some_and(char::is_numeric) {
result.insert_str(0, "project_");
}
if is_keyword(&result) {
result.push_str("_project");
}
result
}
fn valid_module_name(value: &str) -> bool {
let mut characters = value.chars();
let Some(first) = characters.next() else {
return false;
};
(first == '_' || first.is_alphabetic())
&& characters.all(|character| character == '_' || character.is_alphanumeric())
&& !is_keyword(value)
}
fn is_keyword(value: &str) -> bool {
matches!(
value,
"module"
| "import"
| "enum"
| "entity"
| "derive"
| "decision"
| "rule"
| "case"
| "invariant"
| "when"
| "then"
| "source"
| "override"
| "let"
| "expect"
| "for"
| "all"
| "some"
| "if"
| "exactly"
| "one"
| "zero"
| "or"
| "many"
| "cardinality"
| "range"
| "domain"
| "optional"
| "and"
| "not"
| "true"
| "false"
| "unknown"
| "continue"
)
}
fn escape_basic_string(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
fn scaffold_files(project_name: &str, module: &str) -> Vec<(&'static str, String)> {
vec![
(
MANIFEST_NAME,
format!(
"[project]\nname = \"{}\"\nversion = \"0.1.0\"\nentry = \"src/main.tes\"\n",
escape_basic_string(project_name)
),
),
(
"src/main.tes",
format!(
"// This is the project entry point. Imports are relative to this file.\n\
// Every imported file repeats the same module name: a Tess module can span files.\n\
module {module}\n\
import \"model/request.tes\"\n\
import \"rules/eligibility.tes\"\n\
import \"../tests/cases.tes\"\n"
),
),
(
"src/model/request.tes",
format!(
"// Domain vocabulary belongs in model/.\n\
module {module}\n\n\
enum Result {{\n Eligible,\n Ineligible,\n}}\n\n\
entity Request {{\n enabled: Bool,\n}}\n\n\
decision eligibility(Request request) -> Result\n"
),
),
(
"src/rules/eligibility.tes",
format!(
"// Policy rules belong in rules/. Rules propose candidates for a decision.\n\
module {module}\n\nrule enabled_request(Request request):\n when request.enabled\n then eligibility(request) = Eligible\n\nrule disabled_request(Request request):\n when not request.enabled\n then eligibility(request) = Ineligible\n"
),
),
(
"tests/cases.tes",
format!(
"// Executable examples and invariants belong in tests/.\n\
module {module}\n\ncase enabled_is_eligible:\n let request = Request {{\n enabled: true,\n }}\n expect eligibility(request) = Eligible\n\ncase disabled_is_ineligible:\n let request = Request {{\n enabled: false,\n }}\n expect eligibility(request) = Ineligible\n\ninvariant eligibility_is_total:\n for all request: Request\n exactly one eligibility(request)\n"
),
),
(
"README.md",
format!(
"# {project_name}\n\n\
This project was created by `tess init`. Its Tess module is `{module}`.\n\n\
## Layout\n\n\
- `tess.toml` names the project and selects `src/main.tes` as its entry.\n\
- `src/main.tes` is the import graph root; keep it small.\n\
- `src/model/` defines domain vocabulary: enums, entities, derives, and decisions.\n\
- `src/rules/` contains policy rules, grouped by business or legal concern.\n\
- `tests/` contains executable cases and quantified invariants.\n\n\
All `.tes` files are partial files of one module, so they repeat `module {module}`.\n\
Imports use paths relative to the importing file. Declarations share one project-wide namespace.\n\n\
## Commands\n\n\
Run these from this directory:\n\n\
```sh\n\
tess validate .\n\
tess lint . --warnings-as-errors\n\
tess test .\n\
tess verify .\n\
tess fmt .\n\
```\n\n\
Every command also accepts `tess.toml` or the entry `.tes` file.\n"
),
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
fn temp_path(label: &str) -> PathBuf {
let id = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"tess-cli-project-{label}-{}-{id}",
std::process::id()
))
}
#[test]
fn parses_minimal_manifest() {
let manifest = parse_manifest(
"# Tess project\n[project]\nname = \"shipping\"\nentry = \"src/main.tes\"\n",
)
.unwrap();
assert_eq!(manifest.name, "shipping");
assert_eq!(manifest.version, Version::new(0, 0, 0));
assert_eq!(manifest.entry, Path::new("src/main.tes"));
assert!(manifest.exports.is_empty());
assert!(manifest.dependencies.is_empty());
}
#[test]
fn parses_exports_and_selective_path_dependencies() {
let manifest = parse_manifest(
"[project]\nname = \"app\"\nversion = \"1.2.3\"\nentry = \"src/main.tes\"\n\
[exports]\nsymbols = [\"Subject\", \"outcome\"]\n\
[dependencies.common]\npath = \"../common\"\nversion = \"^2.1\"\nsymbols = [\"Subject\"]\n",
)
.unwrap();
assert_eq!(manifest.version, Version::new(1, 2, 3));
assert_eq!(
manifest.exports,
BTreeSet::from(["Subject".into(), "outcome".into()])
);
let dependency = &manifest.dependencies["common"];
assert_eq!(dependency.path.as_deref(), Some(Path::new("../common")));
assert_eq!(dependency.version, Some(VersionReq::parse("^2.1").unwrap()));
assert_eq!(dependency.symbols, Some(BTreeSet::from(["Subject".into()])));
}
#[test]
fn language_keywords_are_reserved_for_generated_module_names() {
assert_eq!(module_name("invariant"), "invariant_project");
assert!(!valid_module_name("invariant"));
assert_eq!(module_name("some"), "some_project");
assert!(!valid_module_name("some"));
assert!(valid_module_name("check"));
}
#[test]
fn scaffold_is_complete_and_non_destructive() {
let directory = temp_path("scaffold");
let module = scaffold(&directory, Some("scaffold")).unwrap();
assert_eq!(module, "scaffold");
let target = resolve_target(&directory).unwrap();
assert!(target.entry.ends_with("src/main.tes"));
assert!(directory.join("src/model/request.tes").is_file());
assert!(directory.join("src/rules/eligibility.tes").is_file());
assert!(directory.join("tests/cases.tes").is_file());
let error = scaffold(&directory, None).unwrap_err();
assert!(error.contains("non-empty"));
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn resolves_and_locks_transitive_local_packages_deterministically() {
let directory = temp_path("transitive-lock");
let write = |relative: &str, contents: &str| {
let path = directory.join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
};
write(
"utility/tess.toml",
"[project]\nname = \"utility\"\nversion = \"1.4.0\"\nentry = \"main.tes\"\n\
[exports]\nsymbols = [\"allowed\"]\n",
);
write(
"utility/main.tes",
"module utility\nderive allowed(Bool enabled) -> Bool: enabled\n",
);
write(
"policy/tess.toml",
"[project]\nname = \"policy\"\nversion = \"2.1.0\"\nentry = \"main.tes\"\n\
[exports]\nsymbols = [\"Subject\"]\n\
[dependencies.utility]\npath = \"../utility\"\nversion = \"^1.2\"\nsymbols = [\"allowed\"]\n",
);
write(
"policy/main.tes",
"module policy\nentity Subject { enabled: Bool }\n",
);
write(
"app/tess.toml",
"[project]\nname = \"app\"\nversion = \"3.0.0\"\nentry = \"main.tes\"\n\
[dependencies.policy]\npath = \"../policy\"\nversion = \"~2.1\"\nsymbols = [\"Subject\"]\n",
);
write("app/main.tes", "module app\n");
let lock_path = directory.join("app/tess.lock");
let error = resolve_document_target(&directory.join("app/main.tes")).unwrap_err();
assert!(error.contains("is missing"), "{error}");
assert!(
!lock_path.exists(),
"passive editor resolution must be read-only"
);
assert_eq!(update_lockfile(&directory.join("app")).unwrap(), lock_path);
let target = resolve_target(&directory.join("app")).unwrap();
assert_eq!(target.dependencies.len(), 1);
assert_eq!(target.dependencies[0].dependency.alias, "policy");
assert_eq!(target.dependencies[0].dependencies.len(), 1);
assert_eq!(
target.dependencies[0].dependencies[0].dependency.alias,
"utility"
);
let first = fs::read_to_string(&lock_path).unwrap();
assert!(first.contains("lock-version = 1"));
assert!(first.contains("name = \"app\""));
assert!(first.contains("name = \"policy\""));
assert!(first.contains("name = \"utility\""));
assert!(first.contains(
"alias = \"policy\", requirement = \"~2.1\", manifest = \"../policy/tess.toml\", symbols = [\"Subject\"]"
));
assert!(first.contains(
"alias = \"utility\", requirement = \"^1.2\", manifest = \"../utility/tess.toml\", symbols = [\"allowed\"]"
));
resolve_target(&directory.join("app")).unwrap();
assert_eq!(fs::read_to_string(&lock_path).unwrap(), first);
fs::write(&lock_path, "tampered\n").unwrap();
let error = resolve_target(&directory.join("app")).unwrap_err();
assert!(error.contains("out of date"), "{error}");
update_lockfile(&directory.join("app")).unwrap();
assert_eq!(fs::read_to_string(&lock_path).unwrap(), first);
write(
"utility/tess.toml",
"[project]\nname = \"utility\"\nversion = \"9.0.0\"\nentry = \"main.tes\"\n\
[exports]\nsymbols = [\"allowed\"]\n",
);
let error = update_lockfile(&directory.join("app")).unwrap_err();
assert!(error.contains("requires version `^1.2`"), "{error}");
assert_eq!(
fs::read_to_string(&lock_path).unwrap(),
first,
"failed resolution must preserve the previous lockfile"
);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn rejects_unsatisfied_local_package_versions_and_cycles() {
let directory = temp_path("version-cycle");
let write = |relative: &str, contents: &str| {
let path = directory.join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
};
write(
"library/tess.toml",
"[project]\nname = \"library\"\nversion = \"1.0.0\"\nentry = \"main.tes\"\n",
);
write("library/main.tes", "module library\n");
write(
"app/tess.toml",
"[project]\nname = \"app\"\nentry = \"main.tes\"\n\
[dependencies.library]\npath = \"../library\"\nversion = \">=2\"\n",
);
write("app/main.tes", "module app\n");
let error = resolve_target(&directory.join("app")).unwrap_err();
assert!(error.contains("requires version `>=2`"), "{error}");
write(
"library/tess.toml",
"[project]\nname = \"library\"\nversion = \"1.0.0\"\nentry = \"main.tes\"\n\
[dependencies.app]\npath = \"../app\"\n",
);
write(
"app/tess.toml",
"[project]\nname = \"app\"\nentry = \"main.tes\"\n\
[dependencies.library]\npath = \"../library\"\n",
);
let error = resolve_target(&directory.join("app")).unwrap_err();
assert!(error.contains("dependency cycle"), "{error}");
fs::remove_dir_all(directory).unwrap();
}
}