use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
pub const VAR: &str = "SPECKIT_PYTHON_EXECUTABLE";
const LEGACY: &str = "SPECKIT_PYTHON";
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
const PROBE: &str = "import sys, yaml; raise SystemExit(sys.version_info.major != 3)";
pub const FIX: &[&str] = &[
"python3 -m pip install pyyaml",
"export SPECKIT_PYTHON_EXECUTABLE=/path/to/a/python-with-pyyaml",
];
#[must_use]
pub fn command_env(env: &dyn Fn(&str) -> Option<String>) -> Vec<(String, String)> {
let set = |k: &str| env(k).is_some_and(|v| !v.is_empty());
if set(VAR) || set(LEGACY) {
return Vec::new();
}
specify_interpreter(env("PATH").as_deref())
.map(|p| vec![(VAR.to_owned(), p.display().to_string())])
.unwrap_or_default()
}
#[must_use]
pub fn specify_interpreter(path: Option<&str>) -> Option<PathBuf> {
static FOUND: OnceLock<Mutex<HashMap<PathBuf, Option<PathBuf>>>> = OnceLock::new();
let lookup = path.map(|p| vec![("PATH".to_owned(), p.to_owned())]);
let specify = crate::sandbox::resolve_executable(
"specify",
Path::new("/"),
lookup.as_deref().unwrap_or_default(),
)?;
let cache = FOUND.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(found) = cache.lock()
&& let Some(hit) = found.get(&specify)
{
return hit.clone();
}
let hit = discover(&specify, path);
if let Ok(mut found) = cache.lock() {
found.insert(specify, hit.clone());
}
hit
}
#[must_use]
pub fn discover(specify: &Path, path: Option<&str>) -> Option<PathBuf> {
let mut candidates = Vec::new();
if let Some(p) = shebang(specify, path) {
candidates.push(p);
}
if let Some(dir) = specify.parent() {
candidates.extend(["python3", "python"].map(|n| dir.join(n)));
}
let hidden = hidden();
candidates
.into_iter()
.filter(|p| !hidden.iter().any(|h| p.starts_with(h)))
.find(|p| imports_yaml(p))
}
fn shebang(script: &Path, path: Option<&str>) -> Option<PathBuf> {
use std::io::Read as _;
let mut head = [0u8; 512];
let n = std::fs::File::open(script)
.and_then(|mut f| f.read(&mut head))
.ok()?;
let text = String::from_utf8_lossy(&head[..n]);
let line = text.lines().next()?.strip_prefix("#!")?;
let mut words = line.split_whitespace();
let program = PathBuf::from(words.next()?);
let python = |p: &Path| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("python"))
};
if program.file_name().is_some_and(|n| n == "env") {
let name = words.find(|w| !w.starts_with('-'))?;
let lookup = path.map(|p| vec![("PATH".to_owned(), p.to_owned())]);
return python(Path::new(name))
.then(|| {
crate::sandbox::resolve_executable(
name,
Path::new("/"),
lookup.as_deref().unwrap_or_default(),
)
})
.flatten();
}
python(&program).then_some(program)
}
fn hidden() -> Vec<PathBuf> {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| {
crate::sandbox::HIDDEN
.iter()
.map(|h| home.join(h))
.collect()
})
.unwrap_or_default()
}
#[must_use]
pub fn imports_yaml(python: &Path) -> bool {
let Ok(mut child) = Command::new(python)
.args(["-c", PROBE])
.current_dir(std::env::temp_dir())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
return false;
};
let began = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => return status.success(),
Ok(None) if began.elapsed() > PROBE_TIMEOUT => {
let _ = child.kill();
let _ = child.wait();
return false;
}
Ok(None) => std::thread::sleep(Duration::from_millis(20)),
Err(_) => return false,
}
}
}
#[must_use]
pub fn presets(root: &Path) -> Vec<String> {
let mut ids: Vec<String> = std::fs::read_dir(root.join(".specify/presets"))
.into_iter()
.flatten()
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| !n.starts_with('.'))
.collect();
ids.sort();
ids
}
pub fn resolver(env: &dyn Fn(&str) -> Option<String>) -> Result<String, String> {
let path = env("PATH");
for var in [VAR, LEGACY] {
if let Some(v) = env(var).filter(|v| !v.is_empty()) {
let lookup = path.as_ref().map(|p| vec![("PATH".to_owned(), p.clone())]);
let found = crate::sandbox::resolve_executable(
&v,
Path::new("/"),
lookup.as_deref().unwrap_or_default(),
);
if found.as_deref().is_some_and(imports_yaml) {
return Ok(format!("{var}={v}"));
}
return Err(format!(
"{var}={v} is not a Python 3 that imports PyYAML, and an override set by you is kept"
));
}
}
if let Some(p) = specify_interpreter(path.as_deref()) {
return Ok(format!("{} (specify's own Python)", p.display()));
}
let lookup = path.as_ref().map(|p| vec![("PATH".to_owned(), p.clone())]);
let python3 = crate::sandbox::resolve_executable(
"python3",
Path::new("/"),
lookup.as_deref().unwrap_or_default(),
);
match python3 {
Some(p) if imports_yaml(&p) => Ok(format!("{} (python3 on PATH)", p.display())),
_ => Err(
"no Python with PyYAML found: neither specify's own interpreter nor python3 on PATH imports yaml"
.to_owned(),
),
}
}
#[must_use]
pub fn warning(root: &Path, env: &dyn Fn(&str) -> Option<String>) -> Option<String> {
let presets = presets(root);
if presets.is_empty() {
return None;
}
resolver(env).err().map(|why| {
format!(
"Spec Kit preset{} {} installed, but {why}: Spec Kit's scripts cannot resolve preset templates, so `speccoder spec tasks` stops (\"Template 'tasks-template' was not found\")",
if presets.len() == 1 { "" } else { "s" },
presets.join(", ")
)
})
}
#[must_use]
pub fn warning_before_add(env: &dyn Fn(&str) -> Option<String>) -> Option<String> {
resolver(env).err().map(|why| {
format!(
"{why}: once a preset is installed, Spec Kit's scripts cannot resolve its templates, so `speccoder spec tasks` stops (\"Template 'tasks-template' was not found\")"
)
})
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
fn fake(dir: &Path, yaml: bool) -> PathBuf {
use std::os::unix::fs::PermissionsExt as _;
let bin = dir.join("bin");
let venv = dir.join("venv");
std::fs::create_dir_all(&bin).unwrap();
std::fs::create_dir_all(&venv).unwrap();
let python = venv.join("python");
std::fs::write(
&python,
"#!/bin/sh\ncase \"$2\" in *'import sys, yaml'*) [ -f \"$(dirname \"$0\")/yaml.py\" ] && exit 0; exit 1;; esac\nexit 2\n",
)
.unwrap();
if yaml {
std::fs::write(venv.join("yaml.py"), "").unwrap();
}
let specify = bin.join("specify");
std::fs::write(
&specify,
format!("#!{}\nprint('specify')\n", python.display()),
)
.unwrap();
for f in [&python, &specify] {
std::fs::set_permissions(f, std::fs::Permissions::from_mode(0o755)).unwrap();
}
python
}
#[test]
fn the_shebang_interpreter_is_passed_when_it_imports_yaml() {
let d = tempfile::tempdir().unwrap();
let dir = std::fs::canonicalize(d.path()).unwrap();
let python = fake(&dir, true);
let path = format!("{}:/usr/bin:/bin", dir.join("bin").display());
let env = |k: &str| (k == "PATH").then(|| path.clone());
assert_eq!(
command_env(&env),
vec![(VAR.to_owned(), python.display().to_string())]
);
for var in [VAR, LEGACY] {
let mine = |k: &str| match k {
"PATH" => Some(path.clone()),
k if k == var => Some("/mine/python".to_owned()),
_ => None,
};
assert!(command_env(&mine).is_empty(), "{var}");
}
}
#[test]
fn an_interpreter_without_yaml_is_not_passed_and_warns_with_a_preset() {
let d = tempfile::tempdir().unwrap();
let dir = std::fs::canonicalize(d.path()).unwrap();
fake(&dir, false);
let path = format!("{}", dir.join("bin").display());
let env = |k: &str| (k == "PATH").then(|| path.clone());
assert!(command_env(&env).is_empty());
let root = dir.join("project");
std::fs::create_dir_all(&root).unwrap();
assert_eq!(warning(&root, &env), None, "no preset, no warning");
std::fs::create_dir_all(root.join(".specify/presets/speccoder")).unwrap();
let w = warning(&root, &env).unwrap_or_default();
assert!(w.contains("speccoder") && w.contains("PyYAML"), "{w}");
assert!(warning_before_add(&env).is_some());
}
#[test]
fn a_uv_tool_trampoline_finds_the_interpreter_beside_it() {
use std::os::unix::fs::PermissionsExt as _;
let d = tempfile::tempdir().unwrap();
let dir = std::fs::canonicalize(d.path()).unwrap();
let python = fake(&dir, true);
let specify = dir.join("venv/specify");
std::fs::write(
&specify,
"#!/bin/sh\n'''exec' \"$(dirname -- \"$(realpath -- \"$0\")\")\"/'python' \"$0\" \"$@\"\n' '''\n",
)
.unwrap();
std::fs::set_permissions(&specify, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(discover(&specify, None), Some(python));
}
}