Skip to main content

dre_core/
packages.rs

1//! Macro packages: reusable macros from another project (e.g. `dre_utils`), declared under
2//! `packages:` in the project's root `dependencies.yml` or `packages.yml`:
3//!
4//! ```yaml
5//! packages:
6//!   - git: https://github.com/acme/dre-finance-macros.git
7//!     revision: v2.3.0          # tag, branch or commit
8//!   - local: ../shared/macros   # a folder on disk, read in place
9//! ```
10//!
11//! A package is a folder with a `dre_package.yml` (`name:`) and a `macros/` folder. `dre deps`
12//! clones git packages into `dre_deps/packages/<name>` and pins the commit in `dre.lock`. Their
13//! macros are called through the package's name: `{{ dre_utils.star_except(...) }}`.
14
15use 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
29/// Root files that may declare `packages:` (and plugins, like any YAML file).
30pub 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    /// The file declaring it, relative to the project root.
44    pub file: PathBuf,
45}
46
47/// An installed package, ready to use.
48#[derive(Debug, Clone, Serialize)]
49pub struct Package {
50    pub name: String,
51    #[serde(skip)]
52    pub dir: PathBuf,
53    /// Macro files, absolute.
54    #[serde(skip)]
55    pub macros: Vec<PathBuf>,
56}
57
58#[derive(Deserialize)]
59struct Manifest {
60    name: String,
61}
62
63/// Whether `name` can name an extension (and its executable): ASCII letters, digits, `-`, `_`.
64pub 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
71/// The `extensions:` entries of the root dependency files: unique valid names, exact versions.
72pub 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
101/// Every `packages:` entry in the root dependency files. Problems go to `diags`.
102pub 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; // the project load reports the parse error
112        };
113        let Ok(dependencies) = crate::config::de::from_node::<DependenciesFile>(&yf.node) else {
114            continue; // the project load reports its shape
115        };
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
218/// Install missing or outdated git packages into `dre_deps/packages/` and pin them in
219/// `dre.lock`. With `install` false, report what's missing instead. Local packages need nothing.
220pub 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(()); // reported with the project load
225    }
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        // Up to date: the lock matches the declaration and that commit is what's installed.
239        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        // Reinstall the locked commit unless the declared revision changed.
253        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        // A stub git (macOS without the command line tools) fails without saying why.
310        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
323/// Clone `url` at `checkout` into a temporary folder, then copy it (without `.git`) into
324/// `dre_deps/packages/<name>`.
325fn 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
345/// The last path segment of a URL or path, for a readable temp folder name.
346fn 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
358/// Copy `from` into `to`, leaving out `.git`.
359fn 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
376/// Remove a folder even when it holds read-only files, as git's object store does on Windows.
377fn 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
394/// Resolve the declared packages to installed folders and their macro files.
395pub 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
459/// `dispatch:` in `dre_project.yml`: per macro namespace, where `dispatch()` looks and in what
460/// order. Unlisted namespaces search the root project first, then the namespace itself.
461pub type DispatchOrder = BTreeMap<String, Vec<String>>;