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}