use std::collections::BTreeMap;
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(
"`teksilo` is in the dependency tree but `teksilo-widgets` is not, so the \
widget API is unavailable.\n\
Enable it: teksilo = {{ version = \"…\", features = [\"widgets\"] }}"
)]
WidgetsDisabled,
#[error(
"teksilo-widgets source is not on disk at {0}.\n\
Run `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 mut crates = BTreeMap::new();
for pkg in packages {
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();
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 })
}
fn is_teksilo_package(name: &str) -> bool {
name == "teksilo" || name.starts_with("teksilo-")
}
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().next().map(|c| c.version.clone())
}
pub fn resolve_with_sources(dir: &Path) -> Result<Resolution, ResolveError> {
let resolution = resolve(dir)?;
let widgets = resolution.widgets().ok_or(ResolveError::WidgetsDisabled)?;
if widgets.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)?;
let widgets = resolution.widgets().ok_or(ResolveError::WidgetsDisabled)?;
if !widgets.has_src() {
return Err(ResolveError::SourceMissing(widgets.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
})
}
#[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);
}
}