1use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19use serde::{Deserialize, Serialize};
20use serde_yaml_ng::Value;
21
22use crate::diag::Diagnostics;
23use crate::lock::{Lock, LockedPackage};
24use crate::plugins::DEPS_DIR;
25use crate::yaml::YamlFile;
26
27pub const DEPENDENCY_FILES: &[&str] = &["dependencies.yml", "packages.yml"];
29pub const PACKAGE_FILE: &str = "dre_package.yml";
30const INSTALLED_COMMIT: &str = ".dre_commit";
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Source {
34 Git { url: String, revision: String },
35 Local { path: String },
36}
37
38#[derive(Debug, Clone)]
39pub struct Declared {
40 pub source: Source,
41 pub file: PathBuf,
43}
44
45#[derive(Debug, Clone, Serialize)]
47pub struct Package {
48 pub name: String,
49 #[serde(skip)]
50 pub dir: PathBuf,
51 #[serde(skip)]
53 pub macros: Vec<PathBuf>,
54}
55
56#[derive(Deserialize)]
57struct Manifest {
58 name: String,
59}
60
61pub fn declared(root: &Path, diags: &mut Diagnostics) -> Vec<Declared> {
63 let mut out: Vec<Declared> = Vec::new();
64 for f in DEPENDENCY_FILES {
65 let path = root.join(f);
66 if !path.is_file() {
67 continue;
68 }
69 let mut quiet = Diagnostics::default();
70 let Some(yf) = YamlFile::load(&path, PathBuf::from(f), &mut quiet) else {
71 continue; };
73 let Some(list) = yf.value.get("packages") else {
74 continue;
75 };
76 let Some(list) = list.as_sequence() else {
77 diags.error(
78 "invalid-packages",
79 Some(yf.display.clone()),
80 yf.line_of("packages", None),
81 "`packages` must be a list",
82 );
83 continue;
84 };
85 for entry in list {
86 let line = yf.line_of("packages", None);
87 let get = |k: &str| entry.get(k).and_then(Value::as_str).map(str::to_string);
88 let source = match (get("git"), get("local"), get("package")) {
89 (Some(url), None, None) => match get("revision") {
90 Some(revision) => Source::Git { url, revision },
91 None => {
92 diags.error(
93 "invalid-packages",
94 Some(yf.display.clone()),
95 line,
96 format!("git package `{url}` needs a `revision` (a tag, branch or commit)"),
97 );
98 continue;
99 }
100 },
101 (None, Some(path), None) => Source::Local { path },
102 (None, None, Some(name)) => {
103 diags.error(
104 "invalid-packages",
105 Some(yf.display.clone()),
106 line,
107 format!("`package: {name}`: registry packages aren't available yet; use `git:` or `local:`"),
108 );
109 continue;
110 }
111 _ => {
112 diags.error(
113 "invalid-packages",
114 Some(yf.display.clone()),
115 line,
116 "each package needs exactly one of `git:` (with `revision:`) or `local:`",
117 );
118 continue;
119 }
120 };
121 let same_place = |a: &Source, b: &Source| match (a, b) {
122 (Source::Git { url: x, .. }, Source::Git { url: y, .. }) => x == y,
123 (Source::Local { path: x }, Source::Local { path: y }) => x == y,
124 _ => false,
125 };
126 match out.iter().find(|d| same_place(&d.source, &source)) {
127 Some(d) if d.source == source => {}
128 Some(d) => diags.error(
129 "conflicting-packages",
130 Some(yf.display.clone()),
131 line,
132 format!(
133 "this package is also declared in {} with a different revision",
134 d.file.display()
135 ),
136 ),
137 None => out.push(Declared {
138 source,
139 file: PathBuf::from(f),
140 }),
141 }
142 }
143 }
144 out
145}
146
147fn manifest_name(dir: &Path) -> Result<String, String> {
148 let p = dir.join(PACKAGE_FILE);
149 let text = std::fs::read_to_string(&p).map_err(|_| format!("{} has no {PACKAGE_FILE}", dir.display()))?;
150 let m: Manifest = serde_yaml_ng::from_str(&text).map_err(|e| format!("{}: {e}", p.display()))?;
151 if m.name.is_empty()
152 || !m.name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
153 || m.name.starts_with(|c: char| c.is_ascii_digit())
154 {
155 return Err(format!(
156 "{}: package name `{}` must be letters, digits and `_`",
157 p.display(),
158 m.name
159 ));
160 }
161 Ok(m.name)
162}
163
164fn installed_dir(root: &Path, name: &str) -> PathBuf {
165 root.join(DEPS_DIR).join("packages").join(name)
166}
167
168pub fn sync(root: &Path, install: bool, mut log: impl FnMut(&str)) -> Result<(), Vec<String>> {
171 let mut diags = Diagnostics::default();
172 let decls = declared(root, &mut diags);
173 if diags.has_errors() {
174 return Ok(()); }
176 let mut lock = Lock::load(root).map_err(|e| vec![e])?;
177 let mut errors = Vec::new();
178 let mut changed = false;
179 for d in &decls {
180 let Source::Git { url, revision } = &d.source else {
181 continue;
182 };
183 let pinned = lock
184 .packages
185 .iter()
186 .find(|(_, l)| &l.git == url)
187 .map(|(n, l)| (n.clone(), l.clone()));
188 if let Some((name, l)) = &pinned
190 && &l.revision == revision
191 && std::fs::read_to_string(installed_dir(root, name).join(INSTALLED_COMMIT))
192 .is_ok_and(|c| c.trim() == l.commit)
193 {
194 continue;
195 }
196 if !install {
197 errors.push(format!(
198 "the package from {url} ({revision}) isn't installed; run `dre deps`"
199 ));
200 continue;
201 }
202 let checkout = match &pinned {
204 Some((_, l)) if &l.revision == revision => l.commit.clone(),
205 _ => revision.clone(),
206 };
207 match install_git(root, url, &checkout) {
208 Ok((name, commit)) => {
209 log(&format!(
210 "package `{name}` from {url} at {revision} ({})",
211 &commit[..commit.len().min(12)]
212 ));
213 if let Some((old, _)) = &pinned
214 && old != &name
215 {
216 lock.packages.remove(old);
217 }
218 let entry = LockedPackage {
219 git: url.clone(),
220 revision: revision.clone(),
221 commit,
222 };
223 if lock.packages.get(&name) != Some(&entry) {
224 lock.packages.insert(name, entry);
225 changed = true;
226 }
227 }
228 Err(e) => errors.push(format!("package from {url}: {e}")),
229 }
230 }
231 if changed && let Err(e) = lock.save(root) {
232 errors.push(e);
233 }
234 if errors.is_empty() { Ok(()) } else { Err(errors) }
235}
236
237const GIT_MISSING: &str =
238 "git isn't installed (needed for git packages); install git, or use a `local:` package";
239
240fn git_works() -> bool {
241 Command::new("git")
242 .arg("--version")
243 .output()
244 .is_ok_and(|o| o.status.success() && !o.stdout.is_empty())
245}
246
247fn git(args: &[&str], cwd: Option<&Path>) -> Result<String, String> {
248 let mut c = Command::new("git");
249 c.args(args);
250 if let Some(d) = cwd {
251 c.current_dir(d);
252 }
253 let out = c.output().map_err(|e| match e.kind() {
254 std::io::ErrorKind::NotFound => GIT_MISSING.to_string(),
255 _ => format!("can't run git: {e}"),
256 })?;
257 if !out.status.success() {
258 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
259 if stderr.is_empty() && !git_works() {
261 return Err(GIT_MISSING.to_string());
262 }
263 let reason = if stderr.is_empty() {
264 format!("exited with {}", out.status)
265 } else {
266 stderr
267 };
268 return Err(format!("git {}: {reason}", args.join(" ")));
269 }
270 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
271}
272
273fn install_git(root: &Path, url: &str, checkout: &str) -> Result<(String, String), String> {
276 let base = root.join(DEPS_DIR).join("packages");
277 std::fs::create_dir_all(&base).map_err(|e| format!("can't create {}: {e}", base.display()))?;
278 let tmp = std::env::temp_dir().join(format!("dre-package-{}-{}", std::process::id(), base_name(url)));
279 remove_dir_force(&tmp);
280 let result = (|| {
281 git(&["clone", "--quiet", url, &tmp.to_string_lossy()], None)?;
282 git(&["checkout", "--quiet", checkout], Some(&tmp))?;
283 let commit = git(&["rev-parse", "HEAD"], Some(&tmp))?;
284 let name = manifest_name(&tmp)?;
285 let dst = installed_dir(root, &name);
286 remove_dir_force(&dst);
287 copy_tree(&tmp, &dst).map_err(|e| format!("can't install into {}: {e}", dst.display()))?;
288 std::fs::write(dst.join(INSTALLED_COMMIT), &commit).map_err(|e| e.to_string())?;
289 Ok((name, commit))
290 })();
291 remove_dir_force(&tmp);
292 result
293}
294
295fn base_name(url: &str) -> String {
297 let url = url.trim_end_matches(['/', '\\']);
298 url.strip_suffix(".git")
299 .unwrap_or(url)
300 .rsplit(['/', '\\', ':'])
301 .next()
302 .unwrap_or("package")
303 .chars()
304 .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
305 .collect()
306}
307
308fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> {
310 std::fs::create_dir_all(to)?;
311 for e in std::fs::read_dir(from)? {
312 let e = e?;
313 if e.file_name() == ".git" {
314 continue;
315 }
316 let dst = to.join(e.file_name());
317 if e.file_type()?.is_dir() {
318 copy_tree(&e.path(), &dst)?;
319 } else {
320 std::fs::copy(e.path(), dst)?;
321 }
322 }
323 Ok(())
324}
325
326fn remove_dir_force(dir: &Path) {
328 if !dir.exists() {
329 return;
330 }
331 for e in walkdir::WalkDir::new(dir).into_iter().filter_map(Result::ok) {
332 if let Ok(meta) = e.metadata() {
333 let mut perm = meta.permissions();
334 if perm.readonly() {
335 #[allow(clippy::permissions_set_readonly_false)]
336 perm.set_readonly(false);
337 let _ = std::fs::set_permissions(e.path(), perm);
338 }
339 }
340 }
341 let _ = std::fs::remove_dir_all(dir);
342}
343
344pub fn resolve(root: &Path, decls: &[Declared], diags: &mut Diagnostics) -> Vec<Package> {
346 let lock = Lock::load(root).unwrap_or_default();
347 let mut out: Vec<Package> = Vec::new();
348 for d in decls {
349 let dir = match &d.source {
350 Source::Local { path } => {
351 let p = root.join(path);
352 if !p.is_dir() {
353 diags.error(
354 "package-missing",
355 Some(d.file.clone()),
356 None,
357 format!("local package `{path}` doesn't exist"),
358 );
359 continue;
360 }
361 p
362 }
363 Source::Git { url, .. } => match lock.packages.iter().find(|(_, l)| &l.git == url) {
364 Some((name, _)) if installed_dir(root, name).is_dir() => installed_dir(root, name),
365 _ => {
366 diags.error(
367 "package-missing",
368 Some(d.file.clone()),
369 None,
370 format!("the package from {url} isn't installed; run `dre deps`"),
371 );
372 continue;
373 }
374 },
375 };
376 let name = match manifest_name(&dir) {
377 Ok(n) => n,
378 Err(e) => {
379 diags.error("invalid-package", Some(d.file.clone()), None, e);
380 continue;
381 }
382 };
383 if let Some(other) = out.iter().find(|p| p.name == name) {
384 diags.error(
385 "duplicate-package",
386 Some(d.file.clone()),
387 None,
388 format!(
389 "two packages are named `{name}` ({} and {})",
390 other.dir.display(),
391 dir.display()
392 ),
393 );
394 continue;
395 }
396 let mut macros: Vec<PathBuf> = walkdir::WalkDir::new(dir.join("macros"))
397 .sort_by_file_name()
398 .into_iter()
399 .filter_map(Result::ok)
400 .map(|e| e.into_path())
401 .filter(|p| p.extension().is_some_and(|x| x == "sql"))
402 .collect();
403 macros.sort();
404 out.push(Package { name, dir, macros });
405 }
406 out
407}
408
409pub type DispatchOrder = BTreeMap<String, Vec<String>>;