use std::fs;
use std::path::Path;
use semver::{Version, VersionReq};
use super::{
EPISODIC_DEPENDENCY_NAME, EPISODIC_REQUIRED_MAJOR, EPISODIC_REQUIRED_MANIFEST_PATH,
EPISODIC_REQUIRED_MINOR, EPISODIC_REQUIRED_WORKSPACE_MEMBER, EpisodicLockDependency,
EpisodicManifestDependency,
};
use crate::models::EpisodicSemverProbeResult;
pub(super) fn run_episodic_semver_probe(workspace_dir: &Path) -> EpisodicSemverProbeResult {
let core_manifest = workspace_dir
.join("crates")
.join("axiomsync")
.join("Cargo.toml");
if !core_manifest.exists() {
return EpisodicSemverProbeResult::from_error("missing_axiomsync_crate".to_string());
}
let manifest_text = match fs::read_to_string(&core_manifest) {
Ok(value) => value,
Err(err) => {
return EpisodicSemverProbeResult::from_error(format!(
"manifest_read_error={} path={}",
err,
core_manifest.display()
));
}
};
let manifest_dep = match parse_manifest_episodic_dependency(&manifest_text) {
Ok(dep) => dep,
Err(reason) => return EpisodicSemverProbeResult::from_error(reason),
};
let workspace_manifest = workspace_dir
.join("crates")
.join("episodic")
.join("Cargo.toml");
if !workspace_manifest.exists() {
return EpisodicSemverProbeResult::from_error(format!(
"missing_episodic_workspace_member path={}",
workspace_manifest.display()
));
}
let workspace_manifest_text = match fs::read_to_string(&workspace_manifest) {
Ok(value) => value,
Err(err) => {
return EpisodicSemverProbeResult::from_error(format!(
"workspace_manifest_read_error={} path={}",
err,
workspace_manifest.display()
));
}
};
let workspace_version = match parse_package_version(&workspace_manifest_text) {
Ok(value) => value,
Err(reason) => return EpisodicSemverProbeResult::from_error(reason),
};
let lock_path = workspace_dir.join("Cargo.lock");
if !lock_path.exists() {
return EpisodicSemverProbeResult::from_error(format!(
"missing_workspace_lockfile path={}",
lock_path.display()
));
}
let lock_text = match fs::read_to_string(&lock_path) {
Ok(value) => value,
Err(err) => {
return EpisodicSemverProbeResult::from_error(format!(
"lockfile_read_error={} path={}",
err,
lock_path.display()
));
}
};
let lock_dep = match parse_lockfile_episodic_dependency(&lock_text) {
Ok(dep) => dep,
Err(reason) => return EpisodicSemverProbeResult::from_error(reason),
};
let manifest_path_ok = manifest_dep
.path
.as_deref()
.is_some_and(|path| path.trim() == EPISODIC_REQUIRED_MANIFEST_PATH);
let workspace_version_ok = episodic_lock_version_contract_matches(&workspace_version);
let manifest_req_ok = manifest_dep
.version_req
.as_deref()
.is_some_and(|raw| episodic_manifest_req_contract_matches(raw, &workspace_version));
let manifest_source_ok = manifest_dep.has_path && !manifest_dep.has_git && manifest_path_ok;
let lock_version_ok = lock_dep.version.trim() == workspace_version.trim()
&& episodic_lock_version_contract_matches(&lock_dep.version);
let lock_source_ok = lock_dep.source.is_none();
let passed = manifest_req_ok
&& manifest_source_ok
&& workspace_version_ok
&& lock_version_ok
&& lock_source_ok;
EpisodicSemverProbeResult {
passed,
error: None,
manifest_req: manifest_dep.version_req,
manifest_req_ok: Some(manifest_req_ok),
manifest_path: manifest_dep.path,
manifest_path_ok: Some(manifest_path_ok),
manifest_uses_path: Some(manifest_dep.has_path),
manifest_uses_git: Some(manifest_dep.has_git),
manifest_source_ok: Some(manifest_source_ok),
workspace_member_path: Some(EPISODIC_REQUIRED_WORKSPACE_MEMBER.to_string()),
workspace_member_present: Some(true),
workspace_version: Some(workspace_version),
workspace_version_ok: Some(workspace_version_ok),
lock_version: Some(lock_dep.version),
lock_version_ok: Some(lock_version_ok),
lock_source: lock_dep.source,
lock_source_ok: Some(lock_source_ok),
}
}
pub(super) fn parse_manifest_episodic_dependency(
manifest: &str,
) -> std::result::Result<EpisodicManifestDependency, String> {
let manifest_doc: toml::Value =
toml::from_str(manifest).map_err(|err| format!("manifest_toml_parse_error={err}"))?;
let dependencies = manifest_doc
.get("dependencies")
.and_then(toml::Value::as_table)
.ok_or_else(|| "manifest_missing_dependencies_table".to_string())?;
let episodic = dependencies
.get(EPISODIC_DEPENDENCY_NAME)
.ok_or_else(|| "missing_episodic_dependency".to_string())?;
match episodic {
toml::Value::String(version_req) => Ok(EpisodicManifestDependency {
version_req: Some(version_req.to_string()),
path: None,
git_url: None,
rev: None,
has_path: false,
has_git: false,
}),
toml::Value::Table(fields) => {
let git_url = fields
.get("git")
.and_then(toml::Value::as_str)
.map(str::to_string);
let rev = fields
.get("rev")
.and_then(toml::Value::as_str)
.map(str::to_string);
let version_req = fields
.get("version")
.and_then(toml::Value::as_str)
.map(str::to_string);
let path = fields
.get("path")
.and_then(toml::Value::as_str)
.map(str::to_string);
if git_url.is_some() && rev.is_none() {
return Err("episodic_dependency_missing_rev".to_string());
}
Ok(EpisodicManifestDependency {
version_req,
path,
git_url,
rev,
has_path: fields.contains_key("path"),
has_git: fields.contains_key("git"),
})
}
_ => Err("episodic_dependency_unsupported_shape".to_string()),
}
}
pub(super) fn parse_lockfile_episodic_dependency(
lockfile: &str,
) -> std::result::Result<EpisodicLockDependency, String> {
let lock_doc: toml::Value =
toml::from_str(lockfile).map_err(|err| format!("lockfile_toml_parse_error={err}"))?;
let packages = lock_doc
.get("package")
.and_then(toml::Value::as_array)
.ok_or_else(|| "lockfile_missing_package_array".to_string())?;
let mut candidates = Vec::<EpisodicLockDependency>::new();
for package in packages {
let Some(package_table) = package.as_table() else {
continue;
};
let name = package_table
.get("name")
.and_then(toml::Value::as_str)
.unwrap_or_default();
if name != EPISODIC_DEPENDENCY_NAME {
continue;
}
let version = package_table
.get("version")
.and_then(toml::Value::as_str)
.ok_or_else(|| "lockfile_episodic_missing_version".to_string())?
.to_string();
let source = package_table
.get("source")
.and_then(toml::Value::as_str)
.map(str::to_string);
candidates.push(EpisodicLockDependency { version, source });
}
if candidates.is_empty() {
return Err("missing_episodic_lock_entry".to_string());
}
if candidates.len() == 1 {
return Ok(candidates.remove(0));
}
let required_candidates = candidates
.iter()
.filter(|candidate| {
candidate.source.is_none() && episodic_lock_version_contract_matches(&candidate.version)
})
.cloned()
.collect::<Vec<_>>();
match required_candidates.len() {
1 => Ok(required_candidates
.into_iter()
.next()
.expect("single required candidate")),
0 => Err("ambiguous_episodic_lock_entry_no_workspace_match".to_string()),
_ => Err("ambiguous_episodic_lock_entry_multiple_workspace_matches".to_string()),
}
}
fn parse_package_version(manifest: &str) -> std::result::Result<String, String> {
let manifest_doc: toml::Value =
toml::from_str(manifest).map_err(|err| format!("manifest_toml_parse_error={err}"))?;
manifest_doc
.get("package")
.and_then(toml::Value::as_table)
.and_then(|package| package.get("version"))
.and_then(toml::Value::as_str)
.map(str::to_string)
.ok_or_else(|| "manifest_missing_package_version".to_string())
}
pub(super) fn episodic_manifest_req_contract_matches(raw: &str, workspace_version: &str) -> bool {
let Ok(version) = Version::parse(workspace_version.trim()) else {
return false;
};
VersionReq::parse(raw.trim()).is_ok_and(|requirement| {
requirement.matches(&version)
&& version.major == EPISODIC_REQUIRED_MAJOR
&& version.minor == EPISODIC_REQUIRED_MINOR
})
}
pub(super) fn episodic_lock_version_contract_matches(raw: &str) -> bool {
Version::parse(raw.trim()).is_ok_and(|version| {
version.major == EPISODIC_REQUIRED_MAJOR && version.minor == EPISODIC_REQUIRED_MINOR
})
}
#[cfg(test)]
mod tests {
use super::{episodic_manifest_req_contract_matches, parse_lockfile_episodic_dependency};
#[test]
fn parse_lockfile_episodic_dependency_prefers_workspace_entry_when_multiple_entries_exist() {
let lockfile = r#"
[[package]]
name = "episodic"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "episodic"
version = "0.2.0"
"#;
let parsed = parse_lockfile_episodic_dependency(lockfile).expect("parse lock dependency");
assert_eq!(parsed.version, "0.2.0");
assert_eq!(parsed.source, None);
}
#[test]
fn parse_lockfile_episodic_dependency_rejects_multiple_entries_without_workspace_match() {
let lockfile = r#"
[[package]]
name = "episodic"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "episodic"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
"#;
let err = parse_lockfile_episodic_dependency(lockfile).expect_err("must reject ambiguity");
assert_eq!(err, "ambiguous_episodic_lock_entry_no_workspace_match");
}
#[test]
fn episodic_manifest_req_contract_matches_accepts_workspace_version_requirement() {
assert!(episodic_manifest_req_contract_matches("0.2.2", "0.2.2"));
assert!(episodic_manifest_req_contract_matches("^0.2.2", "0.2.2"));
assert!(!episodic_manifest_req_contract_matches("0.1.0", "0.2.2"));
assert!(!episodic_manifest_req_contract_matches("invalid", "0.2.2"));
}
}