use serde::Deserialize;
use std::path::{Path, PathBuf};
use crate::core::python::{PythonManagerName, PythonUnavailableReason};
pub enum Capability {
Available,
Unavailable {
reason: PythonUnavailableReason,
detail: String,
},
}
pub struct PythonProject {
pub root: PathBuf,
pub manager: PythonManagerName,
}
const ROOT_MARKERS: [&str; 3] = ["pyproject.toml", "uv.lock", "poetry.lock"];
pub fn detect(start: &Path) -> Option<PythonProject> {
let mut current = Some(start);
while let Some(directory) = current {
if ROOT_MARKERS
.iter()
.any(|marker| directory.join(marker).is_file())
{
return Some(PythonProject {
root: directory.to_path_buf(),
manager: manager_of(directory),
});
}
current = directory.parent();
}
None
}
fn manager_of(directory: &Path) -> PythonManagerName {
let manifest = std::fs::read_to_string(directory.join("pyproject.toml"))
.ok()
.and_then(|contents| toml::from_str::<PyProject>(&contents).ok())
.unwrap_or_default();
let uv_evidence = directory.join("uv.lock").is_file() || manifest.has_uv_table();
let poetry_evidence = directory.join("poetry.lock").is_file() || manifest.has_poetry_table();
if poetry_evidence && !uv_evidence {
PythonManagerName::Poetry
} else {
PythonManagerName::Uv
}
}
#[derive(Debug, Default, Deserialize)]
struct PyProject {
#[serde(default)]
tool: Option<PyProjectTool>,
}
#[derive(Debug, Deserialize)]
struct PyProjectTool {
#[serde(default)]
poetry: Option<toml::Value>,
#[serde(default)]
uv: Option<toml::Value>,
}
impl PyProject {
fn has_poetry_table(&self) -> bool {
self.tool.as_ref().is_some_and(|tool| tool.poetry.is_some())
}
fn has_uv_table(&self) -> bool {
self.tool.as_ref().is_some_and(|tool| tool.uv.is_some())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn project(files: &[(&str, &str)]) -> tempfile::TempDir {
let temp = tempfile::tempdir().expect("temp dir");
for (name, contents) in files {
let path = temp.path().join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
std::fs::write(path, contents).expect("write fixture file");
}
temp
}
fn manager(files: &[(&str, &str)]) -> PythonManagerName {
let temp = project(files);
detect(temp.path()).expect("a project was written").manager
}
#[test]
fn each_poetry_signal_is_sufficient_on_its_own() {
assert_eq!(
manager(&[("poetry.lock", "")]),
PythonManagerName::Poetry,
"only Poetry writes a poetry.lock"
);
assert_eq!(
manager(&[("pyproject.toml", "[tool.poetry]\nname = \"x\"\n")]),
PythonManagerName::Poetry,
"[tool.poetry] is Poetry's own configuration table"
);
assert_eq!(
manager(&[(
"pyproject.toml",
"[project]\nname = \"x\"\n\n[build-system]\nbuild-backend = \
\"poetry.core.masonry.api\"\n"
)]),
PythonManagerName::Uv,
"a build backend alone must not reroute a project away from uv"
);
}
#[test]
fn anything_short_of_unambiguous_poetry_stays_on_uv() {
for files in [
&[("pyproject.toml", "[project]\nname = \"x\"\n")][..],
&[("uv.lock", "version = 1\n")][..],
&[("pyproject.toml", "[tool.uv]\n")][..],
&[
(
"pyproject.toml",
"[tool.poetry]\nname = \"x\"\n\n[tool.uv]\n",
),
("poetry.lock", ""),
("uv.lock", "version = 1\n"),
][..],
] {
assert_eq!(manager(files), PythonManagerName::Uv, "{files:?}");
}
}
#[test]
fn an_unparseable_manifest_is_not_a_detection_failure() {
assert_eq!(
manager(&[("pyproject.toml", "this is not toml {{{\n")]),
PythonManagerName::Uv
);
assert_eq!(
manager(&[
("pyproject.toml", "this is not toml {{{\n"),
("poetry.lock", "")
]),
PythonManagerName::Poetry
);
}
#[test]
fn the_innermost_project_wins() {
let temp = project(&[
("poetry.lock", ""),
("pyproject.toml", "[tool.poetry]\nname = \"outer\"\n"),
("nested/uv.lock", "version = 1\n"),
]);
let outer = detect(temp.path()).expect("outer project");
assert_eq!(outer.manager, PythonManagerName::Poetry);
assert_eq!(outer.root, temp.path());
let inner = detect(&temp.path().join("nested")).expect("nested project");
assert_eq!(inner.manager, PythonManagerName::Uv);
assert_eq!(inner.root, temp.path().join("nested"));
}
#[test]
fn the_walk_climbs_to_the_nearest_project_root() {
let temp = project(&[
("pyproject.toml", "[project]\nname = \"x\"\n"),
("src/pkg/module.py", ""),
]);
let found = detect(&temp.path().join("src").join("pkg")).expect("walked up to the root");
assert_eq!(found.root, temp.path());
assert_eq!(found.manager, PythonManagerName::Uv);
}
#[test]
fn a_directory_with_no_project_detects_nothing() {
let temp = project(&[("README.md", "not a python project\n")]);
assert!(detect(temp.path()).is_none());
}
}