Skip to main content

logicaffeine_cli/project/
deps.rs

1//! G10 — local dependency linking: resolve `Largo.toml` `[dependencies]` into the
2//! loader's admitted dependency roots, and describe the assembled linked closure.
3//!
4//! This is the CLI-side seam that turns a manifest's `path`/`git` declarations
5//! into the [`DepRoot`]s the module loader threads through EVERY tier's assembly
6//! (`assemble_closure`), so a bare `Import <Dep>.` links a package living OUTSIDE
7//! the project root. Registry linking is intentionally unsupported here — the
8//! local closure reaches only the filesystem, so a fully-local project builds
9//! with no crates.io access (proved by [`BuildManifest::registry`]).
10
11use std::path::Path;
12
13use logicaffeine_compile::loader::DepRoot;
14use serde::Serialize;
15
16use crate::project::manifest::{DepSource, Manifest, ResolvedDep};
17use crate::ui::CliError;
18
19/// Resolve the manifest's linkable dependencies against `project_root` into the
20/// loader's [`DepRoot`]s. A resolution failure (a floating git rev, a missing
21/// path, an unlinkable form) surfaces as a clean [`CliError`], so every command
22/// that links deps reports the same message.
23pub fn resolve_dep_roots(
24    project_root: &Path,
25    manifest: &Manifest,
26) -> Result<Vec<DepRoot>, CliError> {
27    let resolved = resolve(project_root, manifest)?;
28    Ok(resolved
29        .into_iter()
30        .map(|d| DepRoot {
31            name: d.name,
32            root: d.root,
33        })
34        .collect())
35}
36
37/// The resolved dependencies, surfacing the manifest error as a `CliError`.
38fn resolve(project_root: &Path, manifest: &Manifest) -> Result<Vec<ResolvedDep>, CliError> {
39    manifest
40        .resolve_dependencies(project_root)
41        .map_err(|e| CliError::new(e.to_string()))
42}
43
44/// One file in the assembled closure, tagged with the dependency it came from.
45#[derive(Debug, Clone, Serialize)]
46pub struct ClosureFile {
47    /// The file's path (absolute, canonical).
48    pub path: String,
49    /// The dependency this file belongs to: the local package name for the
50    /// project's own files, else a declared dependency's name.
51    pub dependency: String,
52}
53
54/// Whether — and how — a registry was consulted assembling the closure.
55#[derive(Debug, Clone, Serialize)]
56pub struct RegistryState {
57    /// Always `false` for a local closure: `path`/`git`-local linking reaches
58    /// only the filesystem, never crates.io.
59    pub accessed: bool,
60    /// A human note on why the registry was not needed.
61    pub note: String,
62}
63
64/// The local package's identity in the build manifest.
65#[derive(Debug, Clone, Serialize)]
66pub struct PackageIdentity {
67    pub name: String,
68    pub version: String,
69}
70
71/// A resolved dependency as it appears in the build manifest.
72#[derive(Debug, Clone, Serialize)]
73pub struct ManifestDep {
74    /// The dependency name (its `Import` alias).
75    pub name: String,
76    /// `"path"` or `"git"`.
77    pub kind: String,
78    /// The canonicalized root the dependency's source lives under.
79    pub root: String,
80    /// For a git dependency, the exact commit it is pinned to.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub rev: Option<String>,
83    /// For a git dependency, the repository URL.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub url: Option<String>,
86}
87
88/// A machine-readable account of a linked build: the local package identity,
89/// feature selection, every resolved dependency, the transitive closure (each
90/// linked `.lg` file tagged with its source dependency), and the registry state.
91#[derive(Debug, Clone, Serialize)]
92pub struct BuildManifest {
93    pub package: PackageIdentity,
94    /// The selected feature set. Empty today (LOGOS projects declare no cargo
95    /// features), but the key is stable so tooling can rely on it.
96    pub features: Vec<String>,
97    /// Every resolved `path`/`git` dependency.
98    pub dependencies: Vec<ManifestDep>,
99    /// The transitive closure: every linked `.lg` file with its source dep.
100    pub closure: Vec<ClosureFile>,
101    /// Whether a registry was accessed (always `false` for a local closure).
102    pub registry: RegistryState,
103}
104
105impl BuildManifest {
106    /// Serialize to a stable, pretty JSON string.
107    pub fn to_json(&self) -> String {
108        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
109    }
110}
111
112/// Assemble the linked closure for `entry` and describe it as a [`BuildManifest`].
113///
114/// Every file the closure links is attributed to a dependency: a file rooted
115/// under a declared dependency's root carries that dependency's name; every other
116/// file (the project's own `src/`) carries the local package name. This is the
117/// same `assemble_closure` seam
118/// every tier consumes, so the manifest describes exactly what the tiers link.
119pub fn build_manifest(
120    project_root: &Path,
121    manifest: &Manifest,
122    entry: &Path,
123) -> Result<BuildManifest, CliError> {
124    let resolved = resolve(project_root, manifest)?;
125    let dep_roots: Vec<DepRoot> = resolved
126        .iter()
127        .map(|d| DepRoot {
128            name: d.name.clone(),
129            root: d.root.clone(),
130        })
131        .collect();
132
133    let closure = crate::closure::assemble_closure_with_deps(entry, dep_roots)
134        .map_err(|e| CliError::new(format!("could not assemble project: {e}")))?;
135
136    let local_name = manifest.package.name.clone();
137    let closure_files = closure
138        .files
139        .iter()
140        .map(|path| {
141            let dependency = resolved
142                .iter()
143                .find(|d| path.starts_with(&d.root))
144                .map(|d| d.name.clone())
145                .unwrap_or_else(|| local_name.clone());
146            ClosureFile {
147                path: path.display().to_string(),
148                dependency,
149            }
150        })
151        .collect();
152
153    let dependencies = resolved
154        .iter()
155        .map(|d| match &d.source {
156            DepSource::Path => ManifestDep {
157                name: d.name.clone(),
158                kind: "path".to_string(),
159                root: d.root.display().to_string(),
160                rev: None,
161                url: None,
162            },
163            DepSource::Git { url, rev } => ManifestDep {
164                name: d.name.clone(),
165                kind: "git".to_string(),
166                root: d.root.display().to_string(),
167                rev: Some(rev.clone()),
168                url: Some(url.clone()),
169            },
170        })
171        .collect();
172
173    Ok(BuildManifest {
174        package: PackageIdentity {
175            name: manifest.package.name.clone(),
176            version: manifest.package.version.clone(),
177        },
178        features: Vec::new(),
179        dependencies,
180        closure: closure_files,
181        registry: RegistryState {
182            accessed: false,
183            note: "a fully-local (path/git) closure links from the filesystem only — no registry"
184                .to_string(),
185        },
186    })
187}