use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedCrate {
pub name: String,
pub version: String,
pub dir: PathBuf,
}
impl ResolvedCrate {
pub fn src(&self) -> PathBuf {
self.dir.join("src")
}
pub fn has_src(&self) -> bool {
self.src().is_dir()
}
}
#[derive(Debug, Clone)]
pub struct Resolution {
pub crates: BTreeMap<String, ResolvedCrate>,
pub version: String,
}
impl Resolution {
pub fn get(&self, name: &str) -> Option<&ResolvedCrate> {
self.crates.get(name)
}
pub fn widgets(&self) -> Option<&ResolvedCrate> {
self.get("teksilo-widgets")
}
pub fn checkout_root(&self) -> Option<PathBuf> {
let widgets = self.widgets()?;
let root = widgets.dir.parent()?.parent()?;
let has_tool = root.join("tools").join("extract_widget_api.py").is_file();
let has_crates = root.join("crates").join("teksilo-widgets").is_dir();
(has_tool && has_crates).then(|| root.to_path_buf())
}
}
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("`cargo metadata` failed — run this from inside your app's crate or workspace.\n{0}")]
Metadata(String),
#[error("could not parse `cargo metadata` output: {0}")]
Parse(#[from] serde_json::Error),
#[error("could not run cargo: {0}")]
Spawn(#[from] std::io::Error),
#[error(
"`teksilo` is not in this app's dependency tree.\n\
Add it with `cargo add teksilo`, or run this from the app's directory."
)]
NotADependency,
#[error(
"multiple resolved packages named {0}; run from a single app and remove ambiguous framework dependencies"
)]
Ambiguous(String),
#[error("teksilo source is not on disk at {0}.\nRun `cargo fetch` in your app and try again.")]
SourceMissing(PathBuf),
}
pub fn resolve(dir: &Path) -> Result<Resolution, ResolveError> {
let metadata = run_cargo_metadata(dir, false)?;
resolution_from_metadata(&metadata)
}
pub fn resolve_locked(dir: &Path) -> Result<Resolution, ResolveError> {
let metadata = run_cargo_metadata(dir, true)?;
resolution_from_metadata(&metadata)
}
fn run_cargo_metadata(dir: &Path, locked: bool) -> Result<serde_json::Value, ResolveError> {
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let mut args = vec!["metadata", "--format-version", "1", "--quiet"];
if locked {
args.push("--locked");
}
let out = Command::new(cargo).args(&args).current_dir(dir).output()?;
if !out.status.success() {
return Err(ResolveError::Metadata(
String::from_utf8_lossy(&out.stderr).trim().to_string(),
));
}
Ok(serde_json::from_slice(&out.stdout)?)
}
pub fn resolution_from_metadata(meta: &serde_json::Value) -> Result<Resolution, ResolveError> {
let packages = meta
.get("packages")
.and_then(|p| p.as_array())
.ok_or(ResolveError::NotADependency)?;
let reachable = meta.get("resolve").map(|graph| {
let mut pending: Vec<&str> = match graph.get("root").and_then(|v| v.as_str()) {
Some(root) => vec![root],
None => meta["workspace_members"]
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v.as_str())
.collect(),
};
let mut seen = BTreeSet::new();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
if let Some(node) = graph["nodes"]
.as_array()
.into_iter()
.flatten()
.find(|node| node["id"].as_str() == Some(id))
{
pending.extend(
node["dependencies"]
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v.as_str()),
);
}
}
seen
});
let mut crates = BTreeMap::new();
for pkg in packages {
if reachable
.as_ref()
.is_some_and(|ids| pkg["id"].as_str().is_none_or(|id| !ids.contains(id)))
{
continue;
}
let Some(name) = pkg.get("name").and_then(|n| n.as_str()) else {
continue;
};
if !is_teksilo_package(name) {
continue;
}
let Some(version) = pkg.get("version").and_then(|v| v.as_str()) else {
continue;
};
let Some(manifest) = pkg.get("manifest_path").and_then(|m| m.as_str()) else {
continue;
};
let dir = Path::new(manifest)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
if crates.contains_key(name) {
return Err(ResolveError::Ambiguous(name.to_string()));
}
crates.insert(
name.to_string(),
ResolvedCrate {
name: name.to_string(),
version: version.to_string(),
dir,
},
);
}
if crates.is_empty() {
return Err(ResolveError::NotADependency);
}
let version = framework_version(&crates).ok_or(ResolveError::NotADependency)?;
Ok(Resolution { crates, version })
}
const EXTERNAL_SIBLINGS: [&str; 2] = ["text-document", "text-typeset"];
fn is_teksilo_package(name: &str) -> bool {
name == "teksilo" || name.starts_with("teksilo-") || EXTERNAL_SIBLINGS.contains(&name)
}
fn framework_version(crates: &BTreeMap<String, ResolvedCrate>) -> Option<String> {
for key in ["teksilo", "teksilo-widgets", "teksilo-core"] {
if let Some(c) = crates.get(key) {
return Some(c.version.clone());
}
}
crates
.values()
.find(|c| c.name.starts_with("teksilo-"))
.map(|c| c.version.clone())
}
pub fn resolve_with_sources(dir: &Path) -> Result<Resolution, ResolveError> {
let resolution = resolve(dir)?;
if resolution.crates.values().all(ResolvedCrate::has_src) {
return Ok(resolution);
}
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let _ = Command::new(cargo).arg("fetch").current_dir(dir).output();
let resolution = resolve(dir)?;
if let Some(missing) = resolution.crates.values().find(|c| !c.has_src()) {
return Err(ResolveError::SourceMissing(missing.src()));
}
Ok(resolution)
}
#[cfg(test)]
mod tests {
use super::*;
fn meta(packages: serde_json::Value) -> serde_json::Value {
serde_json::json!({ "packages": packages })
}
fn pkg(name: &str, version: &str, manifest: &str) -> serde_json::Value {
serde_json::json!({
"name": name, "version": version, "manifest_path": manifest
})
}
fn workspace_graph(root: serde_json::Value) -> serde_json::Value {
serde_json::json!({
"packages": [
{"id":"old", "name":"teksilo", "version":"0.13.0", "manifest_path":"/old/Cargo.toml"},
{"id":"new", "name":"teksilo", "version":"0.14.2", "manifest_path":"/new/Cargo.toml"}
],
"workspace_members": ["app-old", "app-new"],
"resolve": {"root":root, "nodes":[
{"id":"app-old", "dependencies":["old"]},
{"id":"app-new", "dependencies":["new"]},
{"id":"old", "dependencies":[]},
{"id":"new", "dependencies":[]}
]}
})
}
#[test]
fn selected_app_excludes_other_workspace_dependencies() {
let r = resolution_from_metadata(&workspace_graph(serde_json::json!("app-old"))).unwrap();
assert_eq!(r.version, "0.13.0");
assert_eq!(r.get("teksilo").unwrap().dir, Path::new("/old"));
}
#[test]
fn conflicting_versions_are_reported_at_virtual_root_or_within_one_app() {
let mut meta = workspace_graph(serde_json::Value::Null);
assert!(matches!(
resolution_from_metadata(&meta),
Err(ResolveError::Ambiguous(_))
));
meta["resolve"]["root"] = serde_json::json!("app-old");
meta["resolve"]["nodes"][0]["dependencies"] = serde_json::json!(["old", "new"]);
assert!(matches!(
resolution_from_metadata(&meta),
Err(ResolveError::Ambiguous(_))
));
}
#[test]
fn external_sibling_alone_is_not_a_framework_dependency() {
let m = meta(serde_json::json!([pkg(
"text-document",
"1.0.0",
"/text/Cargo.toml"
)]));
assert!(matches!(
resolution_from_metadata(&m),
Err(ResolveError::NotADependency)
));
}
#[test]
fn member_only_sources_resolve_without_widgets() {
let t = tempfile::tempdir().unwrap();
std::fs::create_dir(t.path().join("src")).unwrap();
std::fs::write(t.path().join("src/lib.rs"), "pub struct Example;").unwrap();
std::fs::write(t.path().join("Cargo.toml"),
"[package]\nname = \"teksilo-data\"\nversion = \"0.14.2\"\nedition = \"2021\"\n[workspace]\n").unwrap();
let r = resolve_with_sources(t.path()).unwrap();
assert!(r.get("teksilo-data").unwrap().has_src());
assert!(r.widgets().is_none());
}
#[test]
fn workspace_inherited_dependency_resolves() {
let m = meta(serde_json::json!([
pkg("my-app", "0.1.0", "/app/Cargo.toml"),
pkg("teksilo", "0.12.1", "/reg/teksilo-0.12.1/Cargo.toml"),
pkg(
"teksilo-widgets",
"0.12.1",
"/reg/teksilo-widgets-0.12.1/Cargo.toml"
),
]));
let r = resolution_from_metadata(&m).unwrap();
assert_eq!(r.version, "0.12.1");
assert_eq!(
r.widgets().unwrap().dir,
Path::new("/reg/teksilo-widgets-0.12.1")
);
}
#[test]
fn umbrella_version_wins_over_member_order() {
let m = meta(serde_json::json!([
pkg(
"teksilo-core",
"0.12.1",
"/reg/teksilo-core-0.12.1/Cargo.toml"
),
pkg("teksilo", "0.12.1", "/reg/teksilo-0.12.1/Cargo.toml"),
]));
assert_eq!(resolution_from_metadata(&m).unwrap().version, "0.12.1");
}
#[test]
fn a_member_only_app_still_resolves() {
let m = meta(serde_json::json!([pkg(
"teksilo-data",
"0.12.1",
"/reg/teksilo-data-0.12.1/Cargo.toml"
),]));
let r = resolution_from_metadata(&m).unwrap();
assert_eq!(r.version, "0.12.1");
assert!(r.widgets().is_none());
}
#[test]
fn no_teksilo_is_a_named_error() {
let m = meta(serde_json::json!([pkg(
"serde",
"1.0.0",
"/reg/serde/Cargo.toml"
)]));
assert!(matches!(
resolution_from_metadata(&m),
Err(ResolveError::NotADependency)
));
}
#[test]
fn a_lookalike_package_is_not_teksilo() {
assert!(is_teksilo_package("teksilo"));
assert!(is_teksilo_package("teksilo-widgets"));
assert!(!is_teksilo_package("teksilonium"));
assert!(!is_teksilo_package("not-teksilo"));
}
#[test]
fn checkout_root_needs_both_markers() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let widgets_dir = root.join("crates").join("teksilo-widgets");
std::fs::create_dir_all(&widgets_dir).unwrap();
let mut crates = BTreeMap::new();
crates.insert(
"teksilo-widgets".to_string(),
ResolvedCrate {
name: "teksilo-widgets".into(),
version: "0.12.1".into(),
dir: widgets_dir,
},
);
let r = Resolution {
crates,
version: "0.12.1".into(),
};
assert_eq!(r.checkout_root(), None);
std::fs::create_dir_all(root.join("tools")).unwrap();
std::fs::write(root.join("tools").join("extract_widget_api.py"), b"#").unwrap();
assert_eq!(r.checkout_root().unwrap(), root);
}
#[test]
fn a_registry_dependency_is_not_a_checkout() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("registry").join("teksilo-widgets-0.12.1");
std::fs::create_dir_all(&dir).unwrap();
let mut crates = BTreeMap::new();
crates.insert(
"teksilo-widgets".to_string(),
ResolvedCrate {
name: "teksilo-widgets".into(),
version: "0.12.1".into(),
dir,
},
);
let r = Resolution {
crates,
version: "0.12.1".into(),
};
assert_eq!(r.checkout_root(), None);
}
}