dev_prune/adapters/
poetry.rs1use super::uv::{lockfile_package_names, unlocked_packages};
15use super::{
16 BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command_with_timeout,
17};
18use anyhow::{Result, anyhow};
19use std::collections::HashSet;
20use std::fs;
21use std::path::Path;
22
23pub struct Poetry;
25
26fn declares_poetry(path: &Path) -> bool {
31 fs::read_to_string(path.join("pyproject.toml"))
32 .map(|c| {
33 c.lines()
34 .any(|l| l.trim_start().starts_with("[tool.poetry"))
35 })
36 .unwrap_or(false)
37}
38
39fn project_name(path: &Path) -> Option<String> {
46 let content = fs::read_to_string(path.join("pyproject.toml")).ok()?;
47 let mut in_name_table = false;
48 for raw in content.lines() {
49 let line = raw.trim();
50 if line.starts_with('[') {
51 in_name_table = line == "[tool.poetry]" || line == "[project]";
54 continue;
55 }
56 if !in_name_table {
57 continue;
58 }
59 if let Some(rest) = line.strip_prefix("name")
60 && let Some(value) = rest.trim_start().strip_prefix('=')
61 {
62 let value = value.trim().trim_matches('"');
63 if !value.is_empty() {
64 return Some(super::venv::normalize_package_name(value));
65 }
66 }
67 }
68 None
69}
70
71impl PackageManager for Poetry {
72 fn name(&self) -> &'static str {
73 "poetry"
74 }
75
76 fn detect(&self, path: &Path) -> bool {
77 path.join("poetry.lock").exists() || declares_poetry(path)
78 }
79
80 fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
81 let mut dirs = Vec::new();
82 let venv_path = path.join(".venv");
83 if venv_path.exists() {
84 dirs.push(BloatDir {
85 name: ".venv".to_string(),
86 path: venv_path.clone(),
87 size_bytes: dir_size(&venv_path),
88 shared_bytes: 0,
89 });
90 }
91 dirs
92 }
93
94 fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
101 let lockfile = path.join("poetry.lock");
102
103 if !lockfile.exists() && path.join(".venv").exists() {
109 return Err(anyhow!(
110 "`pyproject.toml` declares `[tool.poetry]` but there is no `poetry.lock` \
111 — a generated lockfile could not prove the environment's contents are \
112 recoverable. Lock and rebuild it first: `poetry lock` then \
113 `poetry install`."
114 ));
115 }
116
117 enforce_two_tier(
118 &lockfile,
119 "poetry",
120 &["check", "--lock"],
121 &["lock"],
122 path,
123 policy,
124 )?;
125
126 if let Some(locked) = locked_names_with_project(path, &lockfile) {
131 let extras = unlocked_packages(path, &locked);
132 if !extras.is_empty() {
133 let shown = extras
134 .iter()
135 .take(10)
136 .cloned()
137 .collect::<Vec<_>>()
138 .join(", ");
139 let suffix = if extras.len() > 10 {
140 format!(", … and {} more", extras.len() - 10)
141 } else {
142 String::new()
143 };
144 return Err(anyhow!(
145 "`.venv` holds {} package(s) that poetry.lock does not record \
146 ({shown}{suffix}). They were installed ad hoc and `poetry install` \
147 would not bring them back. Record them first: `poetry add <package>`.",
148 extras.len()
149 ));
150 }
151 }
152 Ok(())
153 }
154
155 fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
156 self.restore_named(path, ".venv", None, timeout)
157 }
158
159 fn restore_named(
162 &self,
163 path: &Path,
164 _dir_name: &str,
165 runtime: Option<&str>,
166 timeout: std::time::Duration,
167 ) -> Result<()> {
168 if let Some(exe) = runtime.and_then(super::python_executable) {
169 let _ = run_command_with_timeout("poetry", &["env", "use", &exe], path, timeout);
176 }
177 run_command_with_timeout("poetry", &["install"], path, timeout)
178 }
179
180 fn runtime_tag(&self, path: &Path, dir_name: &str) -> Option<String> {
182 super::venv_runtime_tag(&path.join(dir_name))
183 }
184
185 fn lockfiles(&self) -> &'static [&'static str] {
186 &["poetry.lock"]
187 }
188
189 fn drift(&self, path: &Path) -> Vec<super::DriftReport> {
192 let Some(locked) = locked_names_with_project(path, &path.join("poetry.lock")) else {
193 return Vec::new();
194 };
195 let extras = unlocked_packages(path, &locked);
196 if extras.is_empty() {
197 return Vec::new();
198 }
199 vec![super::DriftReport {
200 directory: ".venv".to_string(),
201 unrecorded: extras,
202 record_command: "poetry add <package>",
203 }]
204 }
205}
206
207fn locked_names_with_project(path: &Path, lockfile: &Path) -> Option<HashSet<String>> {
210 let mut locked = lockfile_package_names(lockfile)?;
211 if let Some(own) = project_name(path) {
212 locked.insert(own);
213 }
214 Some(locked)
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use std::fs::File;
221 use std::io::Write;
222 use tempfile::tempdir;
223
224 #[test]
225 fn detect_positive_lock() {
226 let dir = tempdir().unwrap();
227 File::create(dir.path().join("poetry.lock")).unwrap();
228 assert!(Poetry.detect(dir.path()));
229 }
230
231 #[test]
232 fn detect_positive_toml() {
233 let dir = tempdir().unwrap();
234 let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
235 writeln!(file, "[tool.poetry]").unwrap();
236 assert!(Poetry.detect(dir.path()));
237 }
238
239 #[test]
240 fn detect_negative_on_a_plain_pep621_project() {
241 let dir = tempdir().unwrap();
242 let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
243 writeln!(file, "[project]\nname = \"plain\"").unwrap();
244 assert!(!Poetry.detect(dir.path()));
245 }
246
247 #[test]
248 fn bloat_dirs_only_claims_an_in_project_venv() {
249 let dir = tempdir().unwrap();
250 assert!(Poetry.bloat_dirs(dir.path()).is_empty());
251 fs::create_dir(dir.path().join(".venv")).unwrap();
252 let dirs = Poetry.bloat_dirs(dir.path());
253 assert_eq!(dirs.len(), 1);
254 assert_eq!(dirs[0].name, ".venv");
255 }
256
257 #[test]
258 fn a_venv_without_a_lockfile_is_refused() {
259 let dir = tempdir().unwrap();
260 let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
261 writeln!(file, "[tool.poetry]\nname = \"proj\"").unwrap();
262 fs::create_dir(dir.path().join(".venv")).unwrap();
263
264 let err = Poetry
265 .enforce_lockfile(dir.path(), EnforcePolicy::default())
266 .unwrap_err();
267 assert!(err.to_string().contains("poetry lock"));
268 }
269
270 #[test]
271 fn the_projects_own_name_is_not_drift() {
272 let dir = tempdir().unwrap();
273 fs::write(
274 dir.path().join("pyproject.toml"),
275 "[tool.poetry]\nname = \"My_Proj\"\n",
276 )
277 .unwrap();
278 fs::write(
279 dir.path().join("poetry.lock"),
280 "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
281 )
282 .unwrap();
283 let sp = dir.path().join(".venv").join("Lib").join("site-packages");
284 fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
285 fs::create_dir_all(sp.join("my_proj-0.1.0.dist-info")).unwrap();
286
287 assert!(Poetry.drift(dir.path()).is_empty());
288 }
289
290 #[test]
291 fn an_ad_hoc_install_is_reported_as_drift() {
292 let dir = tempdir().unwrap();
293 fs::write(
294 dir.path().join("pyproject.toml"),
295 "[tool.poetry]\nname = \"proj\"\n",
296 )
297 .unwrap();
298 fs::write(
299 dir.path().join("poetry.lock"),
300 "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
301 )
302 .unwrap();
303 let sp = dir.path().join(".venv").join("Lib").join("site-packages");
304 fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
305 fs::create_dir_all(sp.join("sneaky_pkg-1.0.dist-info")).unwrap();
306
307 let reports = Poetry.drift(dir.path());
308 assert_eq!(reports.len(), 1);
309 assert_eq!(reports[0].unrecorded, vec!["sneaky-pkg"]);
310 assert_eq!(reports[0].record_command, "poetry add <package>");
311 }
312
313 #[test]
314 fn the_project_name_reads_from_either_table() {
315 let dir = tempdir().unwrap();
316 fs::write(
317 dir.path().join("pyproject.toml"),
318 "[build-system]\nname = \"not-this\"\n\n[project]\nname = \"pep621-name\"\n",
319 )
320 .unwrap();
321 assert_eq!(project_name(dir.path()).as_deref(), Some("pep621-name"));
322 }
323}