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