atom/manifest/
deps.rs

1//! # Atom Dependency Handling
2//!
3//! This module provides the core types for working with an Atom manifest's dependencies.
4//! It defines the structure for specifying different types of dependencies in an atom's
5//! manifest file, including atom references, direct pins, and build-time sources.
6//!
7//! ## Dependency Types
8//!
9//! The manifest supports three main categories of dependencies:
10//!
11//! - **Atom dependencies** - References to other atoms by ID and version
12//! - **Pin dependencies** - Direct references to external sources (URLs, Git repos, tarballs)
13//! - **Source dependencies** - Build-time dependencies like source code or config files
14//!
15//! ## Key Types
16//!
17//! - [`Dependency`] - The main dependency structure containing all dependency types
18//! - [`AtomReq`] - Requirements for atom dependencies
19//! - [`PinReq`] - Requirements for pinned dependencies
20//! - [`SrcReq`] - Requirements for build-time sources
21//! - [`PinType`] - Enum distinguishing between direct and indirect pins
22//!
23//! ## Example Usage
24//!
25//! ```toml
26//! [deps.atoms]
27//! # Reference to another atom
28//! other-atom = { version = "^1.0.0", path = "../other-atom" }
29//!
30//! [deps.pins]
31//! # pin to external evaluation time source code
32//! external-lib = { url = "https://example.com/lib.tar.gz" }
33//!
34//! # Git pin
35//! git-dep = { url = "https://github.com/user/repo.git", ref = "main" }
36//!
37//! # Indirect pin (from another atom)
38//! shared-lib = { from = "other-atom", get = "lib" }
39//!
40//! [deps.srcs]
41//! # Build-time source
42//! src-code = { url = "https://registry.example.com/code.tar.gz" }
43//! ```
44//!
45//! ## Validation
46//!
47//! All dependency types use `#[serde(deny_unknown_fields)]` to ensure strict
48//! validation and prevent typos in manifest files. Optional fields are properly
49//! handled with `skip_serializing_if` to keep the TOML output clean.
50use std::marker::PhantomData;
51use std::path::PathBuf;
52
53use bstr::ByteSlice;
54use gix::url as gix_url;
55use semver::VersionReq;
56use serde::de::DeserializeOwned;
57use serde::{Deserialize, Serialize};
58use toml_edit::DocumentMut;
59use url::Url;
60
61use crate::id::AtomTag;
62use crate::{Lockfile, Manifest};
63
64/// A Writer struct to ensure modifications to the manifest and lock stay in sync
65/// # Example
66///
67/// ```rust,no_run
68/// use std::path::Path;
69///
70/// use atom::id::Name;
71/// use atom::manifest::deps::ManifestWriter;
72/// use atom::uri::Uri;
73///
74/// let mut writer = ManifestWriter::new(Path::new("/path/to/atom.toml")).unwrap();
75/// let uri = "my-atom@^1.0.0".parse::<Uri>().unwrap();
76/// let key = "my-atom".parse::<Name>().unwrap();
77/// writer.add_uri(uri, Some(key)).unwrap();
78/// writer.write_atomic().unwrap();
79/// ```
80pub struct ManifestWriter {
81    path: PathBuf,
82    doc: TypedDocument<Manifest>,
83    lock: Lockfile,
84}
85
86/// Newtype wrapper to tie DocumentMut to a specific serializable type T.
87struct TypedDocument<T> {
88    /// The actual document we want associated with our type
89    inner: DocumentMut,
90    _marker: PhantomData<T>,
91}
92
93/// A trait to implement writing to a mutable toml document representing an atom Manifest
94trait WriteDeps<T: Serialize> {
95    /// The error type returned by the methods.
96    type Error;
97
98    /// write the dep to the given toml doc
99    fn write_dep(&self, name: &str, doc: &mut TypedDocument<T>) -> Result<(), Self::Error>;
100}
101
102#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
103/// The dependencies specified in the manifest
104#[serde(untagged)]
105pub enum Dependency {
106    /// An atom dependency variant.
107    Atom(AtomReq),
108    /// A direct pin to an external source variant.
109    Pin(PinReq),
110    /// A dependency fetched at build-time as an FOD.
111    Src(SrcReq),
112}
113
114#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
115/// Represents a locked atom dependency, referencing a verifiable repository slice.
116#[serde(deny_unknown_fields)]
117pub struct AtomReq {
118    /// The tag of the atom, used if the dependency name in the manifest
119    /// differs from the atom's actual tag.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    tag: Option<AtomTag>,
122    /// The semantic version requirement for the atom (e.g., "^1.0.0").
123    version: VersionReq,
124    /// The Git URL or local path where the atom's repository can be found.
125    #[serde(serialize_with = "serialize_url", deserialize_with = "deserialize_url")]
126    store: gix_url::Url,
127}
128
129#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
130#[serde(untagged)]
131/// Represents the different types of pins for dependencies.
132///
133/// This enum distinguishes between direct pins (pointing to external URLs)
134/// and indirect pins (referencing dependencies from other atoms).
135pub enum PinType {
136    /// A direct pin to an external source with a URL.
137    Direct(DirectPin),
138    /// An indirect pin referencing a dependency from another atom.
139    Indirect(IndirectPin),
140}
141
142#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
143#[serde(untagged)]
144/// Represents the two types of direct pins.
145pub enum DirectPin {
146    /// A simple pin, with an optional unpack field.
147    Straight(Pin),
148    /// A git pin, with a ref or version.
149    Git(GitPin),
150}
151
152#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
153#[serde(deny_unknown_fields)]
154/// Represents a simple pin, with an optional unpack field.
155pub struct Pin {
156    /// The URL of the pinned resource.
157    pub pin: Url,
158    /// If `true`, the resource will be unpacked after fetching.
159    #[serde(skip_serializing_if = "not")]
160    pub unpack: bool,
161}
162
163#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
164#[serde(deny_unknown_fields)]
165/// Represents a direct git pin to an external source.
166///
167/// This struct is used when a dependency is pinned directly to a Git repository.
168pub struct GitPin {
169    /// The URL of the Git repository.
170    pub repo: Url,
171    /// The fetching strategy, either by a specific ref (branch, tag, commit)
172    /// or by resolving a semantic version tag.
173    #[serde(flatten)]
174    pub fetch: GitStrat,
175}
176
177#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
178/// Represents the two types of git fetch strategies.
179pub enum GitStrat {
180    #[serde(rename = "ref")]
181    /// The refspec (e.g. branch or tag) of the source (for git-type pins).
182    Ref(String),
183    #[serde(rename = "version")]
184    /// The version requirement of the source (for git-type pins).
185    Version(VersionReq),
186}
187
188#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
189#[serde(deny_unknown_fields)]
190/// Represents an indirect pin referencing a dependency from another atom.
191///
192/// This struct is used when a dependency is sourced from another atom,
193/// enabling composition of complex systems from simpler atom components.
194pub struct IndirectPin {
195    /// The tag of the atom from which to source the dependency.
196    pub from: AtomTag,
197    /// The name of the dependency to acquire from the source atom. If `None`,
198    /// it defaults to the name of the current dependency.
199    ///
200    /// This field is omitted from serialization if None.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub set: Option<String>,
203}
204
205#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
206/// Represents a direct pin to an external source, such as a URL or tarball.
207///
208/// This struct is used to specify pinned dependencies in the manifest,
209/// which can be either direct (pointing to URLs) or indirect (referencing
210/// dependencies from other atoms).
211#[serde(deny_unknown_fields)]
212pub struct PinReq {
213    /// An optional relative path within the fetched source, useful for Nix imports
214    /// or accessing a subdirectory within an archive.
215    ///
216    /// This field is omitted from serialization if None.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub path: Option<PathBuf>,
219    /// The kind of pin, which can be a direct URL, a Git repository, or an
220    /// indirect reference to a dependency from another atom.
221    ///
222    /// This field is flattened in the TOML serialization.
223    #[serde(flatten)]
224    pub kind: PinType,
225}
226
227#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
228/// Represents a dependency which is fetched at build time as an FOD.
229#[serde(deny_unknown_fields)]
230pub struct SrcReq {
231    /// The URL from which to fetch the build-time source.
232    pub src: Url,
233}
234
235impl AtomReq {
236    /// Creates a new `AtomReq` with the specified version requirement and location.
237    ///
238    /// # Arguments
239    ///
240    /// * `version` - The semantic version requirement for the atom
241    /// * `locale` - The location of the atom, either as a URL or relative path
242    ///
243    /// # Returns
244    ///
245    /// A new `AtomReq` instance with the provided version and location.
246    pub fn new(version: VersionReq, store: gix_url::Url, tag: Option<AtomTag>) -> Self {
247        Self {
248            version,
249            store,
250            tag,
251        }
252    }
253
254    /// return a reference to the version
255    pub fn version(&self) -> &VersionReq {
256        &self.version
257    }
258
259    /// set the version to a new value
260    pub fn set_version(&mut self, version: VersionReq) {
261        self.version = version
262    }
263
264    /// return a reference to the store location
265    pub fn store(&self) -> &gix_url::Url {
266        &self.store
267    }
268
269    /// return a reference to the atom tag
270    pub fn tag(&self) -> Option<&AtomTag> {
271        self.tag.as_ref()
272    }
273}
274
275#[derive(thiserror::Error, Debug)]
276/// transparent errors for TypedDocument
277pub enum DocError {
278    /// The manifest path access.
279    #[error("the atom directory disappeared or is inaccessible: {0}")]
280    Missing(PathBuf),
281    /// Toml deserialization errors
282    #[error(transparent)]
283    De(#[from] toml_edit::de::Error),
284    /// Toml error
285    #[error(transparent)]
286    Ser(#[from] toml_edit::TomlError),
287    /// Filesystem error
288    #[error(transparent)]
289    Read(#[from] std::io::Error),
290    /// Serialization error
291    #[error(transparent)]
292    Manifest(#[from] toml_edit::ser::Error),
293    /// Serialization error
294    #[error(transparent)]
295    Write(#[from] tempfile::PersistError),
296    /// Git resolution error
297    #[error(transparent)]
298    Git(#[from] Box<crate::store::git::Error>),
299    /// Version resolution error
300    #[error(transparent)]
301    Semver(#[from] semver::Error),
302}
303
304impl<T: Serialize + DeserializeOwned> TypedDocument<T> {
305    /// Constructor: Create from a serializable instance of T.
306    /// This enforces that the document comes from serializing T.
307    pub fn new(doc: &str) -> Result<(Self, T), DocError> {
308        let validated: T = toml_edit::de::from_str(doc)?;
309
310        let inner = doc.parse::<DocumentMut>()?;
311        Ok((
312            Self {
313                inner,
314                _marker: PhantomData,
315            },
316            validated,
317        ))
318    }
319}
320
321impl<T: Serialize> AsMut<DocumentMut> for TypedDocument<T> {
322    fn as_mut(&mut self) -> &mut DocumentMut {
323        &mut self.inner
324    }
325}
326impl TypedDocument<Manifest> {
327    /// Write an atom dependency into the manifest document
328    pub fn write_atom_dep(
329        &mut self,
330        key: &str,
331        req: &AtomReq,
332    ) -> Result<(), toml_edit::ser::Error> {
333        req.write_dep(key, self)
334    }
335}
336
337impl AsMut<AtomReq> for AtomReq {
338    fn as_mut(&mut self) -> &mut AtomReq {
339        self
340    }
341}
342
343impl WriteDeps<Manifest> for AtomReq {
344    type Error = toml_edit::ser::Error;
345
346    fn write_dep(&self, key: &str, doc: &mut TypedDocument<Manifest>) -> Result<(), Self::Error> {
347        let doc = doc.as_mut();
348        let atom_table = toml_edit::ser::to_document(self)?.as_table().to_owned();
349
350        if !doc.contains_table("deps") {
351            doc["deps"] = toml_edit::table();
352        }
353
354        let deps = doc["deps"].as_table_mut().unwrap();
355        deps.set_implicit(true);
356        deps.set_position(deps.len() + 1);
357
358        doc["deps"][key] = toml_edit::Item::Table(atom_table);
359        Ok(())
360    }
361}
362
363fn not(b: &bool) -> bool {
364    !b
365}
366
367use serde::{Deserializer, Serializer};
368pub(crate) fn serialize_url<S>(url: &gix_url::Url, serializer: S) -> Result<S::Ok, S::Error>
369where
370    S: Serializer,
371{
372    let str = url.to_string();
373    serializer.serialize_str(&str)
374}
375
376pub(crate) fn deserialize_url<'de, D>(deserializer: D) -> Result<gix_url::Url, D::Error>
377where
378    D: Deserializer<'de>,
379{
380    use bstr::BString;
381    let name = BString::deserialize(deserializer)?;
382    gix_url::parse(name.as_bstr())
383        .map_err(|e| <D::Error as serde::de::Error>::custom(e.to_string()))
384}
385
386use std::path::Path;
387
388use crate::id::Name;
389use crate::uri::Uri;
390impl ManifestWriter {
391    /// Construct a new instance of a manifest writer ensuring all the constraints necessary to keep
392    /// the lock and manifest in sync are respected.
393    pub fn new(path: &Path) -> Result<Self, DocError> {
394        use std::ffi::OsStr;
395        use std::fs;
396        let path = if path.file_name() == Some(OsStr::new(crate::MANIFEST_NAME.as_str())) {
397            path.into()
398        } else {
399            path.join(crate::MANIFEST_NAME.as_str())
400        };
401        let lock_path = path.with_file_name(crate::LOCK_NAME.as_str());
402        let toml_str = fs::read_to_string(&path).inspect_err(|_| {
403            tracing::error!(message = "No atom exists", path = %path.display());
404        })?;
405        let (doc, manifest) = TypedDocument::new(&toml_str)?;
406
407        let mut lock = if let Ok(lock_str) = fs::read_to_string(&lock_path) {
408            toml_edit::de::from_str(&lock_str)?
409        } else {
410            Lockfile::default()
411        };
412        lock.sanitize(&manifest);
413
414        Ok(ManifestWriter { doc, lock, path })
415    }
416
417    /// After processing all changes, write the changes to the manifest and lock to disk. This
418    /// method should be called last, after processing any requested changes.
419    pub fn write_atomic(&mut self) -> Result<(), DocError> {
420        use std::io::Write;
421
422        use tempfile::NamedTempFile;
423
424        let dir = self
425            .path
426            .parent()
427            .ok_or(DocError::Missing(self.path.clone()))?;
428        let lock_path = self.path.with_file_name(crate::LOCK_NAME.as_str());
429        let mut tmp =
430            NamedTempFile::with_prefix_in(format!(".{}", crate::MANIFEST_NAME.as_str()), dir)?;
431        let mut tmp_lock =
432            NamedTempFile::with_prefix_in(format!(".{}", crate::LOCK_NAME.as_str()), dir)?;
433        tmp.write_all(self.doc.as_mut().to_string().as_bytes())?;
434        tmp_lock.write_all(toml_edit::ser::to_string_pretty(&self.lock)?.as_bytes())?;
435        tmp.persist(&self.path)?;
436        tmp_lock.persist(lock_path)?;
437        Ok(())
438    }
439
440    /// Function to add a user requested atom uri to the manifest and lock files, ensuring they
441    /// remain in sync.
442    pub fn add_uri(&mut self, uri: Uri, key: Option<Name>) -> Result<(), DocError> {
443        use crate::lock::Dep;
444
445        let tag = uri.tag();
446        let maybe_version = uri.version();
447        let url = uri.url();
448
449        let req = if let Some(v) = maybe_version {
450            v
451        } else {
452            &VersionReq::STAR
453        };
454
455        let key = if let Some(key) = key {
456            key
457        } else {
458            tag.to_owned()
459        };
460
461        if let Some(url) = url {
462            let mut atom: AtomReq = AtomReq::new(
463                req.to_owned(),
464                url.to_owned(),
465                (&key != tag).then(|| tag.to_owned()),
466            );
467            let lock_entry = atom.resolve(&key).map_err(Box::new)?;
468
469            if maybe_version.is_none() {
470                let version = VersionReq::parse(lock_entry.version.to_string().as_str())?;
471                atom.set_version(version);
472            };
473
474            self.doc.write_atom_dep(key.as_str(), &atom)?;
475            if self
476                .lock
477                .deps
478                .as_mut()
479                .insert(key.to_owned(), Dep::Atom(lock_entry))
480                .is_some()
481            {
482                tracing::warn!("updating lock entry for `{}`", key);
483            }
484        } else {
485            // search locally for atom tag
486            todo!()
487        }
488
489        Ok(())
490    }
491}