use serde::Deserialize;
use std::io::{BufRead, BufReader, Read};
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"];
const REQUIREMENTS_MARKERS: [&str; 2] = ["requirements.txt", "requirements.in"];
pub const REQUIREMENTS_OUTDATED_DETAIL: &str =
"Neither pip nor pip-tools reports newer versions for a requirements file: `pip list \
--outdated` describes an installed environment rather than the pinned requirements, and \
`pip-compile --upgrade` re-resolves the file rather than reporting on it. No install closes \
this gap — uv or Poetry can answer it for a project they manage.";
pub const REQUIREMENTS_SECURITY_DETAIL: &str =
"Neither pip nor pip-tools ships a vulnerability scanner, and `uv audit` requires a \
pyproject.toml rather than a requirements file, so there is nothing here to scan with. No \
install closes this gap — uv or Poetry can answer it for a project they manage, and a \
dedicated scanner can answer it in place.";
const HEADER_SCAN_BYTES: u64 = 4096;
pub fn detect(start: &Path) -> Option<PythonProject> {
if let Some(root) = walk_to_root(start, &ROOT_MARKERS) {
let manager = manager_of(&root);
return Some(PythonProject { root, manager });
}
walk_to_root(start, &REQUIREMENTS_MARKERS).map(|root| {
let manager = requirements_manager_of(&root);
PythonProject { root, manager }
})
}
fn walk_to_root(start: &Path, markers: &[&str]) -> Option<PathBuf> {
let mut current = Some(start);
while let Some(directory) = current {
if markers
.iter()
.any(|marker| directory.join(marker).is_file())
{
return Some(directory.to_path_buf());
}
current = directory.parent();
}
None
}
fn requirements_manager_of(directory: &Path) -> PythonManagerName {
if directory.join("requirements.in").is_file()
|| has_pip_compile_header(&directory.join("requirements.txt"))
{
PythonManagerName::PipTools
} else {
PythonManagerName::Pip
}
}
fn has_pip_compile_header(path: &Path) -> bool {
let Ok(file) = std::fs::File::open(path) else {
return false;
};
let mut reader = BufReader::new(file.take(HEADER_SCAN_BYTES));
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => return false,
Ok(_) => {}
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Some(comment) = trimmed.strip_prefix('#') else {
return false;
};
if comment.to_ascii_lowercase().contains("pip-compile") {
return true;
}
}
}
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());
}
const PIP_COMPILE_HEADER: &str = "#\n\
# This file is autogenerated by pip-compile with Python 3.12\n\
# by the following command:\n\
#\n\
# pip-compile requirements.in\n\
#\n";
#[test]
fn a_requirements_in_sibling_detects_pip_tools() {
assert_eq!(
manager(&[
("requirements.in", "requests\n"),
("requirements.txt", "requests==2.32.3\n"),
]),
PythonManagerName::PipTools,
"only pip-compile consumes a requirements.in"
);
assert_eq!(
manager(&[("requirements.in", "requests\n")]),
PythonManagerName::PipTools
);
}
#[test]
fn a_bare_requirements_txt_detects_pip() {
assert_eq!(
manager(&[("requirements.txt", "requests==2.32.3\njinja2==3.1.4\n")]),
PythonManagerName::Pip
);
assert_eq!(
manager(&[(
"requirements.txt",
"# production pins, reviewed quarterly\nrequests==2.32.3\n"
)]),
PythonManagerName::Pip
);
}
#[test]
fn a_pip_compile_header_detects_pip_tools_without_an_in_file() {
assert_eq!(
manager(&[(
"requirements.txt",
&format!("{PIP_COMPILE_HEADER}requests==2.32.3\n")
)]),
PythonManagerName::PipTools
);
assert_eq!(
manager(&[(
"requirements.txt",
"# Autogenerated by PIP-COMPILE\nrequests==2.32.3\n"
)]),
PythonManagerName::PipTools
);
}
#[test]
fn pip_compile_named_below_the_leading_comment_block_is_not_a_header() {
assert_eq!(
manager(&[(
"requirements.txt",
"requests==2.32.3\n# installed with pip-compile once, years ago\npip-tools==7.6.1\n"
)]),
PythonManagerName::Pip,
"only the leading comment block is a header"
);
}
#[test]
fn a_requirements_txt_beside_a_pyproject_changes_nothing() {
assert_eq!(
manager(&[
("pyproject.toml", "[project]\nname = \"x\"\n"),
("requirements.txt", "requests==2.32.3\n"),
]),
PythonManagerName::Uv,
"a requirements file beside a manifest does not change the manager"
);
assert_eq!(
manager(&[
("pyproject.toml", "[tool.poetry]\nname = \"x\"\n"),
("poetry.lock", ""),
("requirements.in", "requests\n"),
(
"requirements.txt",
&format!("{PIP_COMPILE_HEADER}requests==2.32.3\n")
),
]),
PythonManagerName::Poetry
);
}
#[test]
fn a_nested_requirements_txt_does_not_shadow_the_project_above_it() {
let temp = project(&[
("pyproject.toml", "[project]\nname = \"x\"\n"),
("deploy/requirements.txt", "requests==2.32.3\n"),
]);
let found = detect(&temp.path().join("deploy")).expect("walked up to the project root");
assert_eq!(
found.manager,
PythonManagerName::Uv,
"a nested requirements file must not reroute the project above it"
);
assert_eq!(
found.root,
temp.path(),
"the project root is the pyproject.toml directory, not the subdirectory"
);
}
#[test]
fn the_requirements_walk_climbs_to_its_own_root() {
let temp = project(&[
("requirements.txt", "requests==2.32.3\n"),
("src/app.py", ""),
]);
let found = detect(&temp.path().join("src")).expect("walked up to the requirements root");
assert_eq!(found.manager, PythonManagerName::Pip);
assert_eq!(found.root, temp.path());
}
}