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