use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};
use crate::models::{
CliError, InstalledDependency, InstalledPackage, LocalDependencySpec, PackageManifestFile,
PackagesState, ReleaseDependencySpec, ReleaseResponse, GRITPACK_MANIFEST_FILE,
};
use crate::resolver::LoadedProjectResolver;
use crate::runtime::unix_now;
use crate::{sanitize_archive_path, sanitize_relative_package_path};
pub(crate) const RECOVERED_PACKAGE_UUID_PREFIX: &str = "recovered:";
pub(crate) async fn load_project_manifest(path: &Path) -> Result<PackageManifestFile, CliError> {
let text = tokio::fs::read_to_string(path).await?;
let mut manifest: PackageManifestFile = toml::from_str(&text)?;
manifest.normalize_stages();
Ok(manifest)
}
pub(crate) fn resolve_project_manifest_path(project_root: &Path) -> Result<PathBuf, CliError> {
let manifest_path = project_root.join(GRITPACK_MANIFEST_FILE);
if manifest_path.exists() {
return Ok(manifest_path);
}
Err(CliError::Message(format!(
"no project manifest found in {} (expected {})",
project_root.display(),
GRITPACK_MANIFEST_FILE
)))
}
pub(crate) fn requested_dependency_dialect<'a>(
spec: &'a LocalDependencySpec,
root_dialect: &'a str,
) -> &'a str {
spec.dialect
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(root_dialect)
}
pub(crate) fn requested_dependency_target(spec: &LocalDependencySpec) -> Option<&str> {
spec.target
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub(crate) fn requested_release_dependency_dialect<'a>(
spec: &'a ReleaseDependencySpec,
inherited_dialect: &'a str,
) -> &'a str {
spec.dialect
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(inherited_dialect)
}
pub(crate) fn requested_release_dependency_dialect_exact(
spec: &ReleaseDependencySpec,
) -> Option<&str> {
spec.dialect
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub(crate) fn requested_release_dependency_target(spec: &ReleaseDependencySpec) -> Option<&str> {
spec.target
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub(crate) fn installed_dependency_dialect<'a>(
dependency: &'a InstalledDependency,
inherited_dialect: &'a str,
) -> &'a str {
dependency
.dialect
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(inherited_dialect)
}
pub(crate) fn installed_dependency_dialect_exact(dependency: &InstalledDependency) -> Option<&str> {
dependency
.dialect
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub(crate) fn installed_dependency_target(dependency: &InstalledDependency) -> Option<&str> {
dependency
.target
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub(crate) fn normalized_manifest_target(target: Option<&str>) -> &str {
target
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("generic")
}
pub(crate) fn package_source_root(
package_root: &Path,
source_root: Option<&str>,
) -> Result<PathBuf, CliError> {
let Some(source_root) = source_root else {
return Ok(package_root.to_path_buf());
};
let relative = sanitize_relative_package_path(Path::new(source_root))?;
Ok(package_root.join(relative))
}
pub(crate) fn installed_library_dirs(package: &InstalledPackage) -> Result<Vec<PathBuf>, CliError> {
let package_root = PathBuf::from(&package.install_path);
let mut directories = BTreeSet::new();
for library in &package.libraries {
for file in &library.files {
let relative = sanitize_archive_path(Path::new(file))?;
let absolute = package_root.join(relative);
if !absolute.exists() {
return Err(CliError::Message(format!(
"installed library file not found for {} {}: {}",
package.name,
library.name,
absolute.display()
)));
}
directories.insert(
absolute
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| package_root.clone()),
);
}
}
Ok(directories.into_iter().collect())
}
pub(crate) fn installed_tool_dirs(package: &InstalledPackage) -> Result<Vec<PathBuf>, CliError> {
let package_root = PathBuf::from(&package.install_path);
let mut directories = BTreeSet::new();
for executable in &package.executables {
let absolute = crate::environment::installed_executable_path(package, executable)?;
directories.insert(
absolute
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| package_root.clone()),
);
}
Ok(directories.into_iter().collect())
}
pub(crate) fn manifest_relative_dirs(
manifest_root: &Path,
dirs: Vec<PathBuf>,
) -> Result<Vec<String>, CliError> {
dirs.into_iter()
.map(|dir| manifest_relative_path_string(manifest_root, &dir))
.collect()
}
pub(crate) fn manifest_relative_path_string(
manifest_root: &Path,
target: &Path,
) -> Result<String, CliError> {
let manifest_root = absolutize_path(manifest_root)?;
let target = absolutize_path(target)?;
let relative = relative_path_from(&manifest_root, &target).unwrap_or(target);
Ok(normalize_manifest_path(&relative))
}
pub(crate) fn absolutize_path(path: &Path) -> Result<PathBuf, CliError> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(std::env::current_dir()?.join(path))
}
}
pub(crate) fn relative_path_from(base: &Path, target: &Path) -> Option<PathBuf> {
let base_components = base.components().collect::<Vec<_>>();
let target_components = target.components().collect::<Vec<_>>();
let mut common = 0;
while common < base_components.len()
&& common < target_components.len()
&& base_components[common] == target_components[common]
{
common += 1;
}
if common == 0 {
return None;
}
let mut relative = PathBuf::new();
for component in &base_components[common..] {
if matches!(component, Component::Normal(_)) {
relative.push("..");
}
}
for component in &target_components[common..] {
relative.push(component.as_os_str());
}
if relative.as_os_str().is_empty() {
relative.push(".");
}
Some(relative)
}
pub(crate) fn normalize_manifest_path(path: &Path) -> String {
let raw = path.to_string_lossy().replace('\\', "/");
if raw == "."
|| raw == ".."
|| raw.starts_with("./")
|| raw.starts_with("../")
|| raw.starts_with('/')
{
raw
} else {
format!("./{raw}")
}
}
pub(crate) async fn remove_stale_installs(
install_root: &Path,
existing_state: &PackagesState,
planned: &BTreeMap<String, InstalledPackage>,
) -> Result<(), CliError> {
let mut removed_paths = BTreeSet::new();
for existing in &existing_state.installs {
if planned.contains_key(&existing.name) {
continue;
}
let install_path = PathBuf::from(&existing.install_path);
if !install_path.starts_with(install_root) {
continue;
}
if removed_paths.insert(install_path.clone()) && install_path.exists() {
tokio::fs::remove_dir_all(install_path).await?;
}
}
Ok(())
}
pub(crate) fn ensure_selected_package_is_compatible(
name: &str,
existing: &InstalledPackage,
selected: &ReleaseResponse,
constraint: &str,
) -> Result<(), CliError> {
if !version_satisfies(&existing.version, constraint) {
return Err(CliError::Message(format!(
"conflicting version requirements for {}: selected {} does not satisfy {}",
name, existing.version, constraint
)));
}
let selected_dialect = selected.dialect.as_deref().unwrap_or("unknown");
if existing.dialect != selected_dialect {
return Err(CliError::Message(format!(
"conflicting release variants for {}: dialect {} versus {}",
name, existing.dialect, selected_dialect
)));
}
if existing.target != selected.target {
return Err(CliError::Message(format!(
"conflicting release variants for {}: target {} versus {}",
name,
existing.target.as_deref().unwrap_or("generic"),
selected.target.as_deref().unwrap_or("generic")
)));
}
if existing.version != selected.version {
return Err(CliError::Message(format!(
"conflicting release variants for {}: version {} versus {}",
name, existing.version, selected.version
)));
}
Ok(())
}
pub(crate) async fn reconstruct_packages_state_from_disk(
install_root: &Path,
project_manifest: &PackageManifestFile,
existing_resolver: &LoadedProjectResolver,
) -> Result<PackagesState, CliError> {
let direct_dependencies = project_manifest
.dependencies
.as_ref()
.map(|deps| deps.keys().cloned().collect::<BTreeSet<_>>())
.unwrap_or_default();
let mut installs = Vec::new();
if !install_root.exists() {
return Ok(PackagesState {
version: 2,
installs,
});
}
for package_name_entry in std::fs::read_dir(install_root)? {
let package_name_entry = package_name_entry?;
if !package_name_entry.file_type()?.is_dir() {
continue;
}
for version_entry in std::fs::read_dir(package_name_entry.path())? {
let version_entry = version_entry?;
if !version_entry.file_type()?.is_dir() {
continue;
}
for dialect_entry in std::fs::read_dir(version_entry.path())? {
let dialect_entry = dialect_entry?;
if !dialect_entry.file_type()?.is_dir() {
continue;
}
for variant_entry in std::fs::read_dir(dialect_entry.path())? {
let variant_entry = variant_entry?;
if !variant_entry.file_type()?.is_dir() {
continue;
}
let package_root = variant_entry.path().canonicalize()?;
let manifest_path = package_root.join(GRITPACK_MANIFEST_FILE);
if !manifest_path.exists() {
continue;
}
let manifest = load_project_manifest(&manifest_path).await?;
installs.push(reconstruct_installed_package(
&package_root,
&manifest,
direct_dependencies.contains(&manifest.package.name),
existing_resolver
.preserved_package_for_manifest(&package_root, &manifest)
.as_ref(),
)?);
}
}
}
}
installs.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then(left.version.cmp(&right.version))
.then(left.dialect.cmp(&right.dialect))
.then(normalized_manifest_target(left.target.as_deref()).cmp(normalized_manifest_target(right.target.as_deref())))
});
Ok(PackagesState {
version: 2,
installs,
})
}
pub(crate) fn recovered_package_names(packages_state: &PackagesState) -> Vec<String> {
let mut names = packages_state
.installs
.iter()
.filter(|package| package.package_uuid.starts_with(RECOVERED_PACKAGE_UUID_PREFIX))
.map(|package| package.name.clone())
.collect::<Vec<_>>();
names.sort();
names.dedup();
names
}
fn reconstruct_installed_package(
package_root: &Path,
manifest: &PackageManifestFile,
direct: bool,
existing: Option<&InstalledPackage>,
) -> Result<InstalledPackage, CliError> {
let target = manifest.package.target.clone();
let package_uuid = existing
.map(|package| package.package_uuid.clone())
.unwrap_or_else(|| {
format!(
"{}{name}:{version}:{dialect}:{target}",
RECOVERED_PACKAGE_UUID_PREFIX,
name = manifest.package.name,
version = manifest.package.version,
dialect = manifest.package.dialect,
target = normalized_manifest_target(target.as_deref())
)
});
Ok(InstalledPackage {
name: manifest.package.name.clone(),
version: manifest.package.version.clone(),
dialect: manifest.package.dialect.clone(),
target,
encoding: existing
.map(|package| package.encoding.clone())
.unwrap_or_else(|| "text/utf8".to_string()),
package_uuid,
install_path: package_root.to_string_lossy().to_string(),
source_root: Some(manifest.source.root.clone()),
direct,
dependencies: manifest
.dependencies
.clone()
.unwrap_or_default()
.into_iter()
.map(|(name, spec)| InstalledDependency {
name,
version: spec.version.unwrap_or_else(|| "*".to_string()),
dialect: spec.dialect,
target: spec.target,
})
.collect(),
libraries: manifest.libraries.clone(),
executables: manifest.executables.clone(),
checksum: existing.and_then(|package| package.checksum.clone()),
file_id: existing.and_then(|package| package.file_id.clone()),
installed_at_unix: existing.map(|package| package.installed_at_unix).unwrap_or_else(unix_now),
})
}
pub(crate) fn package_manifest_identity_key(
manifest: &PackageManifestFile,
) -> (String, String, String, String) {
(
manifest.package.name.clone(),
manifest.package.version.clone(),
manifest.package.dialect.clone(),
normalized_manifest_target(manifest.package.target.as_deref()).to_string(),
)
}
pub(crate) fn installed_package_identity_key(
package: &InstalledPackage,
) -> (String, String, String, String) {
(
package.name.clone(),
package.version.clone(),
package.dialect.clone(),
normalized_manifest_target(package.target.as_deref()).to_string(),
)
}
pub(crate) fn existing_package_preferred(candidate: &InstalledPackage, current: &InstalledPackage) -> bool {
candidate
.installed_at_unix
.cmp(¤t.installed_at_unix)
.then(candidate.package_uuid.cmp(¤t.package_uuid))
.is_gt()
}
pub(crate) fn installed_package_matches_selected_release(
existing: &InstalledPackage,
selected: &ReleaseResponse,
fallback_dialect: &str,
) -> bool {
existing.name == selected.name
&& existing.version == selected.version
&& existing.package_uuid == selected.uuid
&& existing.dialect == selected.dialect.as_deref().unwrap_or(fallback_dialect)
&& existing.target == selected.target
}
pub(crate) fn explain_dependency_conflict(
error: CliError,
selected_path: Option<&[String]>,
requested_path: &[String],
) -> CliError {
let message = error.to_string();
let selected = selected_path
.map(format_dependency_path)
.unwrap_or_else(|| "unknown".to_string());
let requested = format_dependency_path(requested_path);
CliError::Message(format!(
"{} (selected via {}; requested via {})",
message, selected, requested
))
}
pub(crate) fn format_dependency_path(path: &[String]) -> String {
if path.is_empty() {
"root".to_string()
} else {
path.join(" -> ")
}
}
#[cfg(test)]
pub(crate) fn ensure_environment_selected_package_is_compatible(
name: &str,
existing: &InstalledPackage,
selected: &ReleaseResponse,
constraint: &str,
) -> Result<(), CliError> {
if !version_satisfies(&existing.version, constraint) {
return Err(CliError::Message(format!(
"conflicting version requirements for {}: selected {} does not satisfy {}",
name, existing.version, constraint
)));
}
let selected_dialect = selected.dialect.as_deref().unwrap_or("unknown");
if existing.dialect != selected_dialect {
return Err(CliError::Message(format!(
"conflicting release variants for {}: dialect {} versus {}",
name, existing.dialect, selected_dialect
)));
}
if existing.target != selected.target {
return Err(CliError::Message(format!(
"conflicting release variants for {}: target {} versus {}",
name,
existing.target.as_deref().unwrap_or("generic"),
selected.target.as_deref().unwrap_or("generic")
)));
}
if existing.version != selected.version {
return Err(CliError::Message(format!(
"conflicting release variants for {}: version {} versus {}",
name, existing.version, selected.version
)));
}
Ok(())
}
pub(crate) fn version_satisfies(version: &str, constraint: &str) -> bool {
if constraint.trim() == "*" {
return true;
}
if let Some(base) = constraint.strip_prefix('^') {
let Ok(version_parts) = parse_version(version) else {
return false;
};
let Ok(base_parts) = parse_version(base) else {
return false;
};
if compare_parts(&version_parts, &base_parts).is_lt() {
return false;
}
let upper = caret_upper_bound(&base_parts);
return compare_parts(&version_parts, &upper).is_lt();
}
version == constraint.trim()
}
pub(crate) fn compare_versions(left: &str, right: &str) -> std::cmp::Ordering {
match (parse_version(left), parse_version(right)) {
(Ok(left), Ok(right)) => compare_parts(&left, &right),
_ => left.cmp(right),
}
}
fn compare_parts(left: &[u64], right: &[u64]) -> std::cmp::Ordering {
let len = left.len().max(right.len());
for index in 0..len {
let left_part = *left.get(index).unwrap_or(&0);
let right_part = *right.get(index).unwrap_or(&0);
match left_part.cmp(&right_part) {
std::cmp::Ordering::Equal => {}
ordering => return ordering,
}
}
std::cmp::Ordering::Equal
}
pub(crate) fn parse_version(version: &str) -> Result<Vec<u64>, CliError> {
version
.trim()
.split('.')
.map(|part| {
part.parse::<u64>().map_err(|_| {
CliError::Message(format!("unsupported version component: {}", version))
})
})
.collect()
}
pub(crate) fn caret_upper_bound(base: &[u64]) -> Vec<u64> {
let mut upper = vec![0; base.len().max(3)];
for (index, value) in base.iter().enumerate() {
upper[index] = *value;
}
if upper[0] > 0 {
upper[0] += 1;
upper[1] = 0;
upper[2] = 0;
} else if upper[1] > 0 {
upper[1] += 1;
upper[2] = 0;
} else {
upper[2] += 1;
}
upper
}