use std::path::{Path, PathBuf};
use std::process::Command;
use crate::guard::{self, Verdict};
use crate::resolve::{self, Resolution, ResolvedCrate};
const EMBEDDED_EXTRACTOR: &str = include_str!("../embedded/extract_widget_api.py");
#[derive(Debug, thiserror::Error)]
pub enum SymbolError {
#[error(transparent)]
Resolve(#[from] resolve::ResolveError),
#[error("{0}")]
Refused(String),
#[error(
"no Python 3 interpreter found on PATH.\n\
The API extractor is a Python script; install Python 3 and try again."
)]
NoPython,
#[error("could not run the API extractor: {0}")]
Io(#[from] std::io::Error),
}
pub fn run(dir: &Path, args: &[String]) -> Result<i32, SymbolError> {
let resolution = resolve::resolve_with_sources(dir)?;
let verdict = guard::check(&resolution.version);
if !verdict.may_answer() {
let Verdict::Refuse { app, tool } = &verdict else {
unreachable!()
};
return Err(SymbolError::Refused(guard::refusal_text(
app,
tool,
"symbol lookup",
guard::readable_sources(&resolution).as_deref(),
)));
}
if let Some(note) = verdict.note() {
crate::output::note(note);
}
let python = find_python().ok_or(SymbolError::NoPython)?;
if let Some(root) = resolution.checkout_root() {
let tool = root.join("tools").join("extract_widget_api.py");
return exec_extractor(&python, &tool, args, &root);
}
let stage = tempfile::Builder::new()
.prefix("cargo-teksilo-")
.tempdir()?;
let tool = stage_monorepo(stage.path(), &resolution)?;
exec_extractor(&python, &tool, args, stage.path())
}
fn stage_monorepo(stage: &Path, resolution: &Resolution) -> Result<PathBuf, SymbolError> {
let tools = stage.join("tools");
let crates = stage.join("crates");
std::fs::create_dir_all(&tools)?;
std::fs::create_dir_all(&crates)?;
let tool = tools.join("extract_widget_api.py");
std::fs::write(&tool, EMBEDDED_EXTRACTOR)?;
for c in resolution.crates.values() {
if !c.has_src() {
continue;
}
link_or_copy_crate(c, &crates.join(&c.name))?;
}
Ok(tool)
}
fn link_or_copy_crate(c: &ResolvedCrate, dest: &Path) -> Result<(), SymbolError> {
if symlink_dir(&c.dir, dest).is_ok() {
return Ok(());
}
std::fs::create_dir_all(dest)?;
copy_tree(&c.src(), &dest.join("src"))?;
Ok(())
}
#[cfg(unix)]
fn symlink_dir(src: &Path, dest: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(src, dest)
}
#[cfg(windows)]
fn symlink_dir(src: &Path, dest: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(src, dest)
}
#[cfg(not(any(unix, windows)))]
fn symlink_dir(_src: &Path, _dest: &Path) -> std::io::Result<()> {
Err(std::io::Error::other(
"symlinks unsupported on this platform",
))
}
fn copy_tree(src: &Path, dest: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dest.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&from, &to)?;
} else {
std::fs::copy(&from, &to)?;
}
}
Ok(())
}
fn exec_extractor(
python: &Path,
tool: &Path,
args: &[String],
cwd: &Path,
) -> Result<i32, SymbolError> {
let mut command = Command::new(python);
command.arg(tool).args(args).current_dir(cwd);
if !crate::output::verbose() {
command.env("TEKSILO_EXTRACTOR_QUIET", "1");
}
let status = command.status()?;
Ok(status.code().unwrap_or(1))
}
pub(crate) fn find_python() -> Option<PathBuf> {
for name in ["python3", "python"] {
if let Some(p) = which(name)
&& is_python3(&p)
{
return Some(p);
}
}
None
}
fn is_python3(python: &Path) -> bool {
Command::new(python)
.args([
"-c",
"import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)",
])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn which(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
let exts: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT")
.unwrap_or_else(|_| ".EXE;.CMD;.BAT".into())
.split(';')
.map(|e| e.to_ascii_lowercase())
.collect()
} else {
vec![String::new()]
};
for dir in std::env::split_paths(&path) {
for ext in &exts {
let candidate = dir.join(format!("{name}{ext}"));
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn fake_crate(root: &Path, name: &str, files: &[(&str, &str)]) -> ResolvedCrate {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("src")).unwrap();
for (rel, body) in files {
let p = dir.join("src").join(rel);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(p, body).unwrap();
}
ResolvedCrate {
name: name.into(),
version: "0.12.1".into(),
dir,
}
}
fn resolution_of(crates: Vec<ResolvedCrate>) -> Resolution {
let mut map = BTreeMap::new();
for c in crates {
map.insert(c.name.clone(), c);
}
Resolution {
crates: map,
version: "0.12.1".into(),
}
}
#[test]
fn named_lookup_works_in_a_stage_without_widgets() {
let Some(python) = find_python() else {
return;
};
let source = tempfile::tempdir().unwrap();
std::fs::create_dir(source.path().join("src")).unwrap();
std::fs::write(source.path().join("src/lib.rs"), "pub mod example;").unwrap();
std::fs::write(source.path().join("src/example.rs"), "pub struct Example;").unwrap();
let c = ResolvedCrate {
name: "teksilo-data".into(),
version: guard::TOOL_VERSION.into(),
dir: source.path().to_path_buf(),
};
let resolution = Resolution {
crates: [(c.name.clone(), c)].into_iter().collect(),
version: guard::TOOL_VERSION.into(),
};
let stage = tempfile::tempdir().unwrap();
let tool = stage_monorepo(stage.path(), &resolution).unwrap();
for args in [vec!["Example"], vec!["--crate", "data", "Example"]] {
let output = Command::new(&python)
.arg(&tool)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stdout).contains("Example"));
}
}
#[test]
fn staging_puts_a_real_copy_of_the_tool_in_place() {
let src = tempfile::tempdir().unwrap();
let stage = tempfile::tempdir().unwrap();
let c = fake_crate(
src.path(),
"teksilo-widgets",
&[("lib.rs", "pub struct Button;")],
);
let tool = stage_monorepo(stage.path(), &resolution_of(vec![c])).unwrap();
assert!(tool.is_file());
let meta = std::fs::symlink_metadata(&tool).unwrap();
assert!(
!meta.file_type().is_symlink(),
"the tool must be copied, not linked"
);
assert_eq!(std::fs::read_to_string(&tool).unwrap(), EMBEDDED_EXTRACTOR);
}
#[test]
fn staging_exposes_every_resolved_crate_source() {
let src = tempfile::tempdir().unwrap();
let stage = tempfile::tempdir().unwrap();
let r = resolution_of(vec![
fake_crate(
src.path(),
"teksilo-widgets",
&[("lib.rs", "pub struct Button;")],
),
fake_crate(
src.path(),
"teksilo-data",
&[("lib.rs", "pub struct ListModel;")],
),
]);
stage_monorepo(stage.path(), &r).unwrap();
for name in ["teksilo-widgets", "teksilo-data"] {
let lib = stage
.path()
.join("crates")
.join(name)
.join("src")
.join("lib.rs");
assert!(lib.is_file(), "{name} source not reachable at {lib:?}");
}
let body = std::fs::read_to_string(stage.path().join("crates/teksilo-widgets/src/lib.rs"))
.unwrap();
assert_eq!(body, "pub struct Button;");
}
#[test]
fn a_crate_with_no_source_on_disk_is_skipped_not_fatal() {
let stage = tempfile::tempdir().unwrap();
let ghost = ResolvedCrate {
name: "teksilo-ghost".into(),
version: "0.12.1".into(),
dir: PathBuf::from("/nonexistent/teksilo-ghost"),
};
stage_monorepo(stage.path(), &resolution_of(vec![ghost])).unwrap();
assert!(!stage.path().join("crates/teksilo-ghost").exists());
}
#[test]
fn the_copy_fallback_reproduces_the_source_tree() {
let src = tempfile::tempdir().unwrap();
let dest = tempfile::tempdir().unwrap();
let c = fake_crate(
src.path(),
"teksilo-widgets",
&[
("lib.rs", "pub struct Button;"),
("primitives/stack.rs", "pub struct HStack;"),
],
);
copy_tree(&c.src(), &dest.path().join("src")).unwrap();
assert!(dest.path().join("src/lib.rs").is_file());
assert!(dest.path().join("src/primitives/stack.rs").is_file());
}
#[test]
fn the_embedded_extractor_is_the_real_tool() {
assert!(EMBEDDED_EXTRACTOR.contains("CRATE_SPECS"));
assert!(EMBEDDED_EXTRACTOR.contains("SPDX-License-Identifier"));
assert!(
EMBEDDED_EXTRACTOR.len() > 50_000,
"suspiciously small extractor"
);
}
#[test]
fn python3_is_discoverable_in_this_environment() {
assert!(find_python().is_some(), "no python3 on PATH");
}
}