Skip to main content

graphcal_compiler/
dag_id.rs

1//! [`DagId`]: an abstract, filesystem-independent identifier for a DAG (module).
2//!
3//! Every file and every `dag` block gets a unique `DagId`. File-based DAGs
4//! derive their segments from the relative path (e.g., `helpers/math.gcl` →
5//! `["helpers", "math"]`), while inline `dag` blocks append their name as an
6//! additional segment (e.g., `["helpers", "math", "double_speed"]`).
7//!
8//! This keeps filesystem concerns (`PathBuf`) in the loader (imperative shell)
9//! and gives the compiler/evaluator (functional core) an opaque identity type.
10
11use std::fmt;
12use std::sync::Arc;
13
14use thiserror::Error;
15
16/// An abstract identifier for a DAG in the compiler pipeline.
17///
18/// Segments form a hierarchical name: for example, a file at `helpers/math.gcl`
19/// has segments `["helpers", "math"]`, and an inline `dag double_speed` within
20/// it has segments `["helpers", "math", "double_speed"]`.
21///
22/// Non-emptiness is encoded structurally as a `head` segment plus an
23/// optional tail, so [`DagId::name`] (the leaf segment) is total — there is
24/// no value of this type that has zero segments.
25///
26/// The compiler never interprets these segments as filesystem paths.
27#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
28pub struct DagId {
29    /// The first segment. Always present.
30    head: Arc<str>,
31    /// Remaining segments after `head`. Empty for a root (single-segment) id.
32    tail: Arc<[Arc<str>]>,
33}
34
35/// Returned by [`DagId::from_relative_path`] when the path is not a valid
36/// graphcal source path.
37#[derive(Debug, Clone, PartialEq, Eq, Error)]
38pub enum DagIdPathError {
39    /// The path produced no components (e.g., an empty `Path`).
40    #[error("path has no components")]
41    Empty,
42    /// A path component was not valid UTF-8.
43    #[error("path contains a non-UTF-8 component")]
44    NonUtf8Component,
45    /// The path did not end with `.gcl`.
46    #[error("path must end with `.gcl`")]
47    MissingGclExtension,
48}
49
50impl DagId {
51    /// Create a `DagId` from a leading segment and any further segments.
52    ///
53    /// The `head` argument enforces non-emptiness at the type level: every
54    /// `DagId` is guaranteed to have at least one segment.
55    pub fn new(
56        head: impl Into<Arc<str>>,
57        tail: impl IntoIterator<Item = impl Into<Arc<str>>>,
58    ) -> Self {
59        Self {
60            head: head.into(),
61            tail: tail.into_iter().map(Into::into).collect(),
62        }
63    }
64
65    /// Create a single-segment (root) `DagId`.
66    pub fn root(name: impl Into<Arc<str>>) -> Self {
67        Self {
68            head: name.into(),
69            tail: Arc::from([] as [Arc<str>; 0]),
70        }
71    }
72
73    /// Create a child `DagId` by appending a segment (e.g., for a nested `dag` block).
74    #[must_use]
75    pub fn child(&self, name: impl Into<Arc<str>>) -> Self {
76        let mut tail: Vec<Arc<str>> = self.tail.to_vec();
77        tail.push(name.into());
78        Self {
79            head: Arc::clone(&self.head),
80            tail: tail.into(),
81        }
82    }
83
84    /// Return the parent `DagId` (all segments except the last), or `None` if
85    /// this is a root (single-segment) identifier.
86    #[must_use]
87    pub fn parent(&self) -> Option<Self> {
88        if self.tail.is_empty() {
89            return None;
90        }
91        Some(Self {
92            head: Arc::clone(&self.head),
93            tail: self.tail[..self.tail.len() - 1].into(),
94        })
95    }
96
97    /// The segments of this identifier as an iterator (head first, then tail).
98    pub fn segments(&self) -> impl Iterator<Item = &Arc<str>> {
99        std::iter::once(&self.head).chain(self.tail.iter())
100    }
101
102    /// Number of segments — always at least 1.
103    #[must_use]
104    pub fn segment_count(&self) -> usize {
105        1 + self.tail.len()
106    }
107
108    /// The last segment (leaf name). Always present.
109    #[must_use]
110    pub fn name(&self) -> &str {
111        self.tail.last().map_or(&self.head, |s| s)
112    }
113
114    /// True if `self` is a strict descendant of `ancestor` (an inline `dag`
115    /// block nested — at any depth — inside the DAG identified by `ancestor`).
116    #[must_use]
117    pub fn is_descendant_of(&self, ancestor: &Self) -> bool {
118        if self.segment_count() <= ancestor.segment_count() {
119            return false;
120        }
121        self.segments()
122            .zip(ancestor.segments())
123            .all(|(a, b)| a == b)
124    }
125
126    /// Create a `DagId` from a relative file path, stripping the `.gcl` extension.
127    ///
128    /// This is the only place where filesystem paths are converted into `DagId`s.
129    /// It belongs at the loader (imperative shell) boundary.
130    ///
131    /// # Errors
132    ///
133    /// Returns [`DagIdPathError`] if `path` has no components, contains a
134    /// non-UTF-8 component, or does not end with `.gcl`.
135    pub fn from_relative_path(path: &std::path::Path) -> Result<Self, DagIdPathError> {
136        let mut segments: Vec<Arc<str>> = path
137            .components()
138            .map(|c| {
139                c.as_os_str()
140                    .to_str()
141                    .map(Arc::<str>::from)
142                    .ok_or(DagIdPathError::NonUtf8Component)
143            })
144            .collect::<Result<_, _>>()?;
145
146        let last = segments.last_mut().ok_or(DagIdPathError::Empty)?;
147        *last = last
148            .strip_suffix(".gcl")
149            .map(Arc::<str>::from)
150            .ok_or(DagIdPathError::MissingGclExtension)?;
151
152        let mut segments = segments.into_iter();
153        let head = segments.next().ok_or(DagIdPathError::Empty)?;
154        let tail: Arc<[Arc<str>]> = segments.collect::<Vec<_>>().into();
155        Ok(Self { head, tail })
156    }
157}
158
159impl fmt::Display for DagId {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        f.write_str(&self.head)?;
162        for seg in self.tail.iter() {
163            f.write_str(".")?;
164            f.write_str(seg)?;
165        }
166        Ok(())
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn from_relative_path_strips_gcl() {
176        let id = DagId::from_relative_path(std::path::Path::new("helpers/math.gcl")).unwrap();
177        let segs: Vec<&str> = id.segments().map(|s| &**s).collect();
178        assert_eq!(segs, ["helpers", "math"]);
179        assert_eq!(id.to_string(), "helpers.math");
180    }
181
182    #[test]
183    fn from_relative_path_rejects_empty_path() {
184        let err = DagId::from_relative_path(std::path::Path::new("")).unwrap_err();
185        assert_eq!(err, DagIdPathError::Empty);
186    }
187
188    #[test]
189    fn from_relative_path_rejects_path_without_gcl_extension() {
190        let err = DagId::from_relative_path(std::path::Path::new("helpers/math")).unwrap_err();
191        assert_eq!(err, DagIdPathError::MissingGclExtension);
192    }
193
194    #[test]
195    fn child_appends_segment() {
196        let parent = DagId::new("helpers", ["math"]);
197        let child = parent.child("double_speed");
198        assert_eq!(child.to_string(), "helpers.math.double_speed");
199    }
200
201    #[test]
202    fn parent_drops_last_segment() {
203        let id = DagId::new("helpers", ["math", "double_speed"]);
204        let parent = id.parent().unwrap();
205        assert_eq!(parent.to_string(), "helpers.math");
206    }
207
208    #[test]
209    fn parent_of_root_is_none() {
210        let id = DagId::root("main");
211        assert!(id.parent().is_none());
212    }
213
214    #[test]
215    fn is_descendant_of_matches_nested_blocks_only() {
216        let file = DagId::new("helpers", ["math"]);
217        let child = file.child("double_speed");
218        let grandchild = child.child("inner");
219        assert!(child.is_descendant_of(&file));
220        assert!(grandchild.is_descendant_of(&file));
221        assert!(!file.is_descendant_of(&file));
222        assert!(!file.is_descendant_of(&child));
223        assert!(!DagId::new("helpers", ["other"]).is_descendant_of(&file));
224    }
225
226    #[test]
227    fn name_returns_last_segment() {
228        let id = DagId::new("helpers", ["math", "double_speed"]);
229        assert_eq!(id.name(), "double_speed");
230    }
231
232    #[test]
233    fn name_of_root_returns_head() {
234        let id = DagId::root("main");
235        assert_eq!(id.name(), "main");
236    }
237
238    #[test]
239    fn display_joins_with_dot() {
240        let id = DagId::new("a", ["b", "c"]);
241        assert_eq!(id.to_string(), "a.b.c");
242    }
243}