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 run_command_with_timeout("poetry", &["install"], path, timeout)
157 }
158
159 fn lockfiles(&self) -> &'static [&'static str] {
160 &["poetry.lock"]
161 }
162
163 fn drift(&self, path: &Path) -> Vec<super::DriftReport> {
166 let Some(locked) = locked_names_with_project(path, &path.join("poetry.lock")) else {
167 return Vec::new();
168 };
169 let extras = unlocked_packages(path, &locked);
170 if extras.is_empty() {
171 return Vec::new();
172 }
173 vec![super::DriftReport {
174 directory: ".venv".to_string(),
175 unrecorded: extras,
176 record_command: "poetry add <package>",
177 }]
178 }
179}
180
181fn locked_names_with_project(path: &Path, lockfile: &Path) -> Option<HashSet<String>> {
184 let mut locked = lockfile_package_names(lockfile)?;
185 if let Some(own) = project_name(path) {
186 locked.insert(own);
187 }
188 Some(locked)
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use std::fs::File;
195 use std::io::Write;
196 use tempfile::tempdir;
197
198 #[test]
199 fn detect_positive_lock() {
200 let dir = tempdir().unwrap();
201 File::create(dir.path().join("poetry.lock")).unwrap();
202 assert!(Poetry.detect(dir.path()));
203 }
204
205 #[test]
206 fn detect_positive_toml() {
207 let dir = tempdir().unwrap();
208 let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
209 writeln!(file, "[tool.poetry]").unwrap();
210 assert!(Poetry.detect(dir.path()));
211 }
212
213 #[test]
214 fn detect_negative_on_a_plain_pep621_project() {
215 let dir = tempdir().unwrap();
216 let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
217 writeln!(file, "[project]\nname = \"plain\"").unwrap();
218 assert!(!Poetry.detect(dir.path()));
219 }
220
221 #[test]
222 fn bloat_dirs_only_claims_an_in_project_venv() {
223 let dir = tempdir().unwrap();
224 assert!(Poetry.bloat_dirs(dir.path()).is_empty());
225 fs::create_dir(dir.path().join(".venv")).unwrap();
226 let dirs = Poetry.bloat_dirs(dir.path());
227 assert_eq!(dirs.len(), 1);
228 assert_eq!(dirs[0].name, ".venv");
229 }
230
231 #[test]
232 fn a_venv_without_a_lockfile_is_refused() {
233 let dir = tempdir().unwrap();
234 let mut file = File::create(dir.path().join("pyproject.toml")).unwrap();
235 writeln!(file, "[tool.poetry]\nname = \"proj\"").unwrap();
236 fs::create_dir(dir.path().join(".venv")).unwrap();
237
238 let err = Poetry
239 .enforce_lockfile(dir.path(), EnforcePolicy::default())
240 .unwrap_err();
241 assert!(err.to_string().contains("poetry lock"));
242 }
243
244 #[test]
245 fn the_projects_own_name_is_not_drift() {
246 let dir = tempdir().unwrap();
247 fs::write(
248 dir.path().join("pyproject.toml"),
249 "[tool.poetry]\nname = \"My_Proj\"\n",
250 )
251 .unwrap();
252 fs::write(
253 dir.path().join("poetry.lock"),
254 "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
255 )
256 .unwrap();
257 let sp = dir.path().join(".venv").join("Lib").join("site-packages");
258 fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
259 fs::create_dir_all(sp.join("my_proj-0.1.0.dist-info")).unwrap();
260
261 assert!(Poetry.drift(dir.path()).is_empty());
262 }
263
264 #[test]
265 fn an_ad_hoc_install_is_reported_as_drift() {
266 let dir = tempdir().unwrap();
267 fs::write(
268 dir.path().join("pyproject.toml"),
269 "[tool.poetry]\nname = \"proj\"\n",
270 )
271 .unwrap();
272 fs::write(
273 dir.path().join("poetry.lock"),
274 "[[package]]\nname = \"requests\"\nversion = \"2.32.3\"\n",
275 )
276 .unwrap();
277 let sp = dir.path().join(".venv").join("Lib").join("site-packages");
278 fs::create_dir_all(sp.join("requests-2.32.3.dist-info")).unwrap();
279 fs::create_dir_all(sp.join("sneaky_pkg-1.0.dist-info")).unwrap();
280
281 let reports = Poetry.drift(dir.path());
282 assert_eq!(reports.len(), 1);
283 assert_eq!(reports[0].unrecorded, vec!["sneaky-pkg"]);
284 assert_eq!(reports[0].record_command, "poetry add <package>");
285 }
286
287 #[test]
288 fn the_project_name_reads_from_either_table() {
289 let dir = tempdir().unwrap();
290 fs::write(
291 dir.path().join("pyproject.toml"),
292 "[build-system]\nname = \"not-this\"\n\n[project]\nname = \"pep621-name\"\n",
293 )
294 .unwrap();
295 assert_eq!(project_name(dir.path()).as_deref(), Some("pep621-name"));
296 }
297}