Skip to main content

shape_runtime/project/
dependency_spec.rs

1//! Dependency specification types for shape.toml `[dependencies]`.
2
3use serde::{Deserialize, Serialize};
4
5use super::permissions::PermissionPreset;
6
7/// A dependency specification: either a version string or a detailed table.
8#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
9#[serde(untagged)]
10pub enum DependencySpec {
11    /// Short form: `finance = "0.1.0"`
12    Version(String),
13    /// Table form: `my-utils = { path = "../utils" }`
14    Detailed(DetailedDependency),
15}
16
17/// Detailed dependency with path, git, or version fields.
18#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
19pub struct DetailedDependency {
20    pub version: Option<String>,
21    pub path: Option<String>,
22    pub git: Option<String>,
23    pub tag: Option<String>,
24    pub branch: Option<String>,
25    pub rev: Option<String>,
26    /// Per-dependency permission override: shorthand ("pure", "readonly", "full")
27    /// or an inline permissions table.
28    #[serde(default)]
29    pub permissions: Option<PermissionPreset>,
30}
31
32/// Normalized native target used for host-aware native dependency resolution.
33#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
34pub struct NativeTarget {
35    pub os: String,
36    pub arch: String,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub env: Option<String>,
39}
40
41impl NativeTarget {
42    /// Build the target description for the current host.
43    pub fn current() -> Self {
44        let env = option_env!("CARGO_CFG_TARGET_ENV")
45            .map(str::trim)
46            .filter(|value| !value.is_empty())
47            .map(str::to_string);
48        Self {
49            os: std::env::consts::OS.to_string(),
50            arch: std::env::consts::ARCH.to_string(),
51            env,
52        }
53    }
54
55    /// Stable ID used in package metadata and lockfile inputs.
56    pub fn id(&self) -> String {
57        match &self.env {
58            Some(env) => format!("{}-{}-{}", self.os, self.arch, env),
59            None => format!("{}-{}", self.os, self.arch),
60        }
61    }
62
63    pub(crate) fn fallback_ids(&self) -> impl Iterator<Item = String> {
64        let mut ids = Vec::with_capacity(3);
65        ids.push(self.id());
66        ids.push(format!("{}-{}", self.os, self.arch));
67        ids.push(self.os.clone());
68        ids.into_iter()
69    }
70}
71
72/// Target-qualified native dependency value.
73#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
74#[serde(untagged)]
75pub enum NativeTargetValue {
76    Simple(String),
77    Detailed(NativeTargetValueDetail),
78}
79
80impl NativeTargetValue {
81    pub fn resolve(&self) -> Option<String> {
82        match self {
83            NativeTargetValue::Simple(value) => Some(value.clone()),
84            NativeTargetValue::Detailed(detail) => {
85                detail.path.clone().or_else(|| detail.value.clone())
86            }
87        }
88    }
89}
90
91/// Detailed target-qualified native dependency value.
92#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
93pub struct NativeTargetValueDetail {
94    #[serde(default)]
95    pub value: Option<String>,
96    #[serde(default)]
97    pub path: Option<String>,
98}
99
100/// Entry in `[native-dependencies]`.
101///
102/// Supports either a shorthand string:
103/// `duckdb = "libduckdb.so"`
104///
105/// Or a platform-specific table:
106/// `duckdb = { linux = "libduckdb.so", macos = "libduckdb.dylib", windows = "duckdb.dll" }`
107#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
108#[serde(untagged)]
109pub enum NativeDependencySpec {
110    Simple(String),
111    Detailed(NativeDependencyDetail),
112}
113
114/// How a native dependency is provisioned.
115#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
116#[serde(rename_all = "lowercase")]
117pub enum NativeDependencyProvider {
118    /// Resolve from system loader search paths / globally installed libraries.
119    System,
120    /// Resolve from a concrete local path (project/dependency checkout).
121    Path,
122    /// Resolve from a vendored artifact and mirror to Shape's native cache.
123    Vendored,
124}
125
126/// Detailed native dependency record.
127#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
128pub struct NativeDependencyDetail {
129    #[serde(default)]
130    pub linux: Option<String>,
131    #[serde(default)]
132    pub macos: Option<String>,
133    #[serde(default)]
134    pub windows: Option<String>,
135    #[serde(default)]
136    pub path: Option<String>,
137    /// Target-qualified entries keyed by normalized target IDs like
138    /// `linux-x86_64-gnu` or `darwin-aarch64`.
139    #[serde(default)]
140    pub targets: std::collections::HashMap<String, NativeTargetValue>,
141    /// Source/provider strategy for this dependency.
142    #[serde(default)]
143    pub provider: Option<NativeDependencyProvider>,
144    /// Optional declared library version used for frozen-mode lock safety,
145    /// especially for system-loaded aliases.
146    #[serde(default)]
147    pub version: Option<String>,
148    /// Optional stable cache key for vendored/native artifacts.
149    #[serde(default)]
150    pub cache_key: Option<String>,
151}
152
153impl NativeDependencySpec {
154    /// Resolve this dependency for an explicit target.
155    pub fn resolve_for_target(&self, target: &NativeTarget) -> Option<String> {
156        match self {
157            NativeDependencySpec::Simple(value) => Some(value.clone()),
158            NativeDependencySpec::Detailed(detail) => {
159                for candidate in target.fallback_ids() {
160                    if let Some(value) = detail
161                        .targets
162                        .get(&candidate)
163                        .and_then(NativeTargetValue::resolve)
164                    {
165                        return Some(value);
166                    }
167                }
168                match target.os.as_str() {
169                    "linux" => detail
170                        .linux
171                        .clone()
172                        .or_else(|| detail.path.clone())
173                        .or_else(|| detail.macos.clone())
174                        .or_else(|| detail.windows.clone()),
175                    "macos" => detail
176                        .macos
177                        .clone()
178                        .or_else(|| detail.path.clone())
179                        .or_else(|| detail.linux.clone())
180                        .or_else(|| detail.windows.clone()),
181                    "windows" => detail
182                        .windows
183                        .clone()
184                        .or_else(|| detail.path.clone())
185                        .or_else(|| detail.linux.clone())
186                        .or_else(|| detail.macos.clone()),
187                    _ => detail
188                        .path
189                        .clone()
190                        .or_else(|| detail.linux.clone())
191                        .or_else(|| detail.macos.clone())
192                        .or_else(|| detail.windows.clone()),
193                }
194            }
195        }
196    }
197
198    /// Resolve this dependency for the current host target.
199    pub fn resolve_for_host(&self) -> Option<String> {
200        self.resolve_for_target(&NativeTarget::current())
201    }
202
203    /// Provider strategy for an explicit target resolution.
204    pub fn provider_for_target(&self, target: &NativeTarget) -> NativeDependencyProvider {
205        match self {
206            NativeDependencySpec::Simple(value) => {
207                if native_dep_looks_path_like(value) {
208                    NativeDependencyProvider::Path
209                } else {
210                    NativeDependencyProvider::System
211                }
212            }
213            NativeDependencySpec::Detailed(detail) => {
214                if let Some(provider) = &detail.provider {
215                    return provider.clone();
216                }
217                if self
218                    .resolve_for_target(target)
219                    .as_deref()
220                    .is_some_and(native_dep_looks_path_like)
221                {
222                    return NativeDependencyProvider::Path;
223                }
224                if detail
225                    .path
226                    .as_deref()
227                    .is_some_and(native_dep_looks_path_like)
228                {
229                    NativeDependencyProvider::Path
230                } else {
231                    NativeDependencyProvider::System
232                }
233            }
234        }
235    }
236
237    /// Provider strategy for current host resolution.
238    pub fn provider_for_host(&self) -> NativeDependencyProvider {
239        self.provider_for_target(&NativeTarget::current())
240    }
241
242    /// Optional declared version for lock safety.
243    pub fn declared_version(&self) -> Option<&str> {
244        match self {
245            NativeDependencySpec::Simple(_) => None,
246            NativeDependencySpec::Detailed(detail) => detail.version.as_deref(),
247        }
248    }
249
250    /// Optional explicit cache key for vendored dependencies.
251    pub fn cache_key(&self) -> Option<&str> {
252        match self {
253            NativeDependencySpec::Simple(_) => None,
254            NativeDependencySpec::Detailed(detail) => detail.cache_key.as_deref(),
255        }
256    }
257}
258
259pub(crate) fn native_dep_looks_path_like(spec: &str) -> bool {
260    let path = std::path::Path::new(spec);
261    path.is_absolute()
262        || spec.starts_with("./")
263        || spec.starts_with("../")
264        || spec.contains('/')
265        || spec.contains('\\')
266        || (spec.len() >= 2 && spec.as_bytes()[1] == b':')
267}
268
269/// Parse the `[native-dependencies]` section table into typed specs.
270pub fn parse_native_dependencies_section(
271    section: &toml::Value,
272) -> Result<std::collections::HashMap<String, NativeDependencySpec>, String> {
273    let table = section
274        .as_table()
275        .ok_or_else(|| "native-dependencies section must be a table".to_string())?;
276
277    let mut out = std::collections::HashMap::new();
278    for (name, value) in table {
279        let spec: NativeDependencySpec =
280            value.clone().try_into().map_err(|e: toml::de::Error| {
281                format!("native-dependencies.{} has invalid format: {}", name, e)
282            })?;
283        out.insert(name.clone(), spec);
284    }
285    Ok(out)
286}