Skip to main content

spec_driven_docs/plan/
operation.rs

1//! What a plan will do to a target, as a closed set.
2//!
3//! An operation names digests and never bytes. The bytes live in the
4//! plan's blob store, so a plan can be read, printed, and compared without
5//! carrying a repository's content around in it, and an apply cannot be
6//! handed content the plan never described.
7//!
8//! There is no operation that runs a command. Every write is a typed write
9//! into one validated target-relative path, so what a plan can do is
10//! bounded by this enum rather than by what a string happens to say.
11
12use camino::{Utf8Path, Utf8PathBuf};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use crate::domain::ownership::Sha256;
17
18/// A path an operation may name: relative, inside the target, and ordinary.
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20#[serde(try_from = "String", into = "String")]
21pub struct TargetPath(Utf8PathBuf);
22
23/// A path no operation may name.
24#[derive(Debug, Clone, PartialEq, Eq, Error)]
25#[error("{0}")]
26pub struct TargetPathError(String);
27
28impl TargetPath {
29    /// Validate one target-relative path.
30    ///
31    /// # Errors
32    ///
33    /// [`TargetPathError`] for an empty path, an absolute path, a path
34    /// that climbs out, a path carrying a NUL, or one with a trailing or
35    /// repeated separator. A plan that could name any of those is a plan
36    /// whose reach the type no longer bounds.
37    pub fn new(value: &str) -> Result<Self, TargetPathError> {
38        let refuse = |why: &str| TargetPathError(format!("{value}: {why}"));
39        if value.is_empty() {
40            return Err(refuse("the path is empty"));
41        }
42        if value.contains('\0') {
43            return Err(refuse("the path carries a NUL"));
44        }
45        let path = Utf8Path::new(value);
46        if path.is_absolute() {
47            return Err(refuse("the path is absolute"));
48        }
49        let mut normalized = Utf8PathBuf::new();
50        for component in path.components() {
51            match component {
52                camino::Utf8Component::Normal(part) => normalized.push(part),
53                camino::Utf8Component::CurDir => {}
54                camino::Utf8Component::ParentDir => {
55                    return Err(refuse("the path climbs out of the target"));
56                }
57                camino::Utf8Component::RootDir | camino::Utf8Component::Prefix(_) => {
58                    return Err(refuse("the path is absolute"));
59                }
60            }
61        }
62        if normalized.as_str().is_empty() {
63            return Err(refuse("the path names no file"));
64        }
65        Ok(Self(normalized))
66    }
67
68    /// The path, relative to the target root.
69    #[must_use]
70    pub fn as_path(&self) -> &Utf8Path {
71        &self.0
72    }
73
74    /// The path as it is written.
75    #[must_use]
76    pub fn as_str(&self) -> &str {
77        self.0.as_str()
78    }
79}
80
81impl TryFrom<String> for TargetPath {
82    type Error = TargetPathError;
83
84    fn try_from(value: String) -> Result<Self, Self::Error> {
85        Self::new(&value)
86    }
87}
88
89impl From<TargetPath> for String {
90    fn from(value: TargetPath) -> Self {
91        value.0.into_string()
92    }
93}
94
95impl std::fmt::Display for TargetPath {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.write_str(self.0.as_str())
98    }
99}
100
101/// Who owns a file once it has landed.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "kebab-case")]
104pub enum Class {
105    /// The canon keeps owning it, byte for byte.
106    Managed,
107    /// The project owns it from the moment it lands.
108    Adopted,
109}
110
111/// One typed write a plan will make.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(tag = "kind", rename_all = "kebab-case")]
114pub enum Operation {
115    /// Put the release's bytes at a destination.
116    WriteFile {
117        /// Where.
118        path: TargetPath,
119        /// Who owns it afterwards.
120        class: Class,
121        /// What the target must still hold, or nothing where it holds none.
122        before: Option<Sha256>,
123        /// What the target will hold.
124        after: Sha256,
125    },
126    /// Keep the target's own bytes and move the baseline they are read
127    /// against.
128    ///
129    /// This is what an adopted file is: seeded once, then the project's.
130    /// An upgrade moves the baseline so the next verification compares the
131    /// project's copy against what the new release would have seeded.
132    KeepFile {
133        /// Where.
134        path: TargetPath,
135        /// What the target holds and keeps holding.
136        held: Sha256,
137        /// The baseline the record carries now.
138        baseline_before: Sha256,
139        /// The baseline the record will carry.
140        baseline_after: Sha256,
141    },
142    /// Replace one marked region inside a file the project owns.
143    SpliceBlock {
144        /// Where.
145        path: TargetPath,
146        /// Which marked region.
147        marker: String,
148        /// The region's current digest, or nothing where it has none.
149        before: Option<Sha256>,
150        /// The region's digest afterwards.
151        after: Sha256,
152    },
153    /// Take back a file the release no longer owns.
154    ///
155    /// And the directory that removal empties, where the directory is one
156    /// the canon owns and is not itself an owned root. A skill is a
157    /// directory holding one file, and an empty directory carrying a
158    /// retired skill's name is one some agents still list. The directory
159    /// is part of what this operation names rather than a second mutation
160    /// beside it, because a file's removal is the only thing that can
161    /// empty it.
162    RemoveOwnedFile {
163        /// Where.
164        path: TargetPath,
165        /// What the target must still hold.
166        before: Sha256,
167    },
168    /// Write the inherited violations the operator asked to record.
169    WriteDebt {
170        /// Where.
171        path: TargetPath,
172        /// What the target must still hold, or nothing where it holds none.
173        before: Option<Sha256>,
174        /// What the target will hold.
175        after: Sha256,
176    },
177    /// Write the instance record itself, last.
178    WriteRecord {
179        /// Where.
180        path: TargetPath,
181        /// What the target must still hold, or nothing where it holds none.
182        before: Option<Sha256>,
183        /// What the target will hold.
184        after: Sha256,
185    },
186}
187
188impl Operation {
189    /// Where this operation writes.
190    #[must_use]
191    pub const fn path(&self) -> &TargetPath {
192        match self {
193            Self::WriteFile { path, .. }
194            | Self::KeepFile { path, .. }
195            | Self::SpliceBlock { path, .. }
196            | Self::RemoveOwnedFile { path, .. }
197            | Self::WriteDebt { path, .. }
198            | Self::WriteRecord { path, .. } => path,
199        }
200    }
201
202    /// The kebab-case kind, as the fingerprint and the JSON spell it.
203    #[must_use]
204    pub const fn kind(&self) -> &'static str {
205        match self {
206            Self::WriteFile { .. } => "write-file",
207            Self::KeepFile { .. } => "keep-file",
208            Self::SpliceBlock { .. } => "splice-block",
209            Self::RemoveOwnedFile { .. } => "remove-owned-file",
210            Self::WriteDebt { .. } => "write-debt",
211            Self::WriteRecord { .. } => "write-record",
212        }
213    }
214
215    /// What the target must still hold for this operation to be the one
216    /// the plan described.
217    #[must_use]
218    pub const fn before(&self) -> Option<&Sha256> {
219        match self {
220            Self::WriteFile { before, .. }
221            | Self::SpliceBlock { before, .. }
222            | Self::WriteDebt { before, .. }
223            | Self::WriteRecord { before, .. } => before.as_ref(),
224            Self::RemoveOwnedFile { before, .. } => Some(before),
225            Self::KeepFile { held, .. } => Some(held),
226        }
227    }
228
229    /// What the target will hold, or nothing where the file goes.
230    #[must_use]
231    pub const fn after(&self) -> Option<&Sha256> {
232        match self {
233            Self::WriteFile { after, .. }
234            | Self::SpliceBlock { after, .. }
235            | Self::WriteDebt { after, .. }
236            | Self::WriteRecord { after, .. } => Some(after),
237            Self::KeepFile { held, .. } => Some(held),
238            Self::RemoveOwnedFile { .. } => None,
239        }
240    }
241}
242
243/// Two operations naming one destination.
244#[derive(Debug, Clone, PartialEq, Eq, Error)]
245#[error("{0} is written by two operations, {1} and {2}")]
246pub struct DuplicateDestination(TargetPath, &'static str, &'static str);
247
248/// Check that no destination is written twice.
249///
250/// # Errors
251///
252/// [`DuplicateDestination`] naming the path and both kinds. A plan that
253/// wrote one destination twice would have an apply order nobody chose.
254pub fn no_duplicate_destination(operations: &[Operation]) -> Result<(), DuplicateDestination> {
255    for (index, operation) in operations.iter().enumerate() {
256        for other in &operations[index + 1..] {
257            if operation.path() == other.path() {
258                return Err(DuplicateDestination(
259                    operation.path().clone(),
260                    operation.kind(),
261                    other.kind(),
262                ));
263            }
264        }
265    }
266    Ok(())
267}
268
269#[cfg(test)]
270mod tests {
271    #![allow(
272        clippy::unwrap_used,
273        reason = "a test panics as its failure signal, not as control flow"
274    )]
275
276    use super::*;
277
278    fn path(value: &str) -> TargetPath {
279        TargetPath::new(value).unwrap()
280    }
281
282    #[test]
283    fn an_ordinary_relative_path_is_accepted_and_normalized() {
284        assert_eq!(
285            path("docs/specs/SPEC-x.md").as_str(),
286            "docs/specs/SPEC-x.md"
287        );
288        assert_eq!(path("./docs/x.md").as_str(), "docs/x.md");
289        assert_eq!(path("docs//x.md").as_str(), "docs/x.md");
290    }
291
292    #[test]
293    fn a_path_a_plan_may_not_name_is_refused() {
294        for value in ["", "/etc/passwd", "../escape.md", "docs/../../out.md"] {
295            assert!(TargetPath::new(value).is_err(), "{value} was accepted");
296        }
297        assert!(TargetPath::new("docs/\0.md").is_err());
298        assert!(TargetPath::new("./").is_err());
299    }
300
301    #[test]
302    fn a_path_round_trips_through_its_string_form() {
303        let held = path("docs/x.md");
304        let json = serde_json::to_string(&held).unwrap();
305        assert_eq!(json, "\"docs/x.md\"");
306        assert_eq!(serde_json::from_str::<TargetPath>(&json).unwrap(), held);
307        assert!(serde_json::from_str::<TargetPath>("\"../x.md\"").is_err());
308    }
309
310    #[test]
311    fn every_operation_names_its_path_and_its_kind() {
312        let write = Operation::WriteFile {
313            path: path("a.md"),
314            class: Class::Managed,
315            before: None,
316            after: Sha256::of(b"a"),
317        };
318        assert_eq!(write.kind(), "write-file");
319        assert_eq!(write.path().as_str(), "a.md");
320        assert_eq!(write.before(), None);
321        assert_eq!(write.after(), Some(&Sha256::of(b"a")));
322
323        let remove = Operation::RemoveOwnedFile {
324            path: path("b.md"),
325            before: Sha256::of(b"b"),
326        };
327        assert_eq!(remove.after(), None);
328        assert_eq!(remove.before(), Some(&Sha256::of(b"b")));
329    }
330
331    #[test]
332    fn a_kept_file_holds_its_bytes_and_moves_only_the_baseline() {
333        let kept = Operation::KeepFile {
334            path: path("docs/specs/SPEC-x.md"),
335            held: Sha256::of(b"mine"),
336            baseline_before: Sha256::of(b"old seed"),
337            baseline_after: Sha256::of(b"new seed"),
338        };
339        assert_eq!(kept.before(), kept.after());
340    }
341
342    #[test]
343    fn one_destination_written_twice_is_refused() {
344        let operations = vec![
345            Operation::WriteFile {
346                path: path("a.md"),
347                class: Class::Managed,
348                before: None,
349                after: Sha256::of(b"a"),
350            },
351            Operation::WriteDebt {
352                path: path("a.md"),
353                before: None,
354                after: Sha256::of(b"b"),
355            },
356        ];
357        let error = no_duplicate_destination(&operations).unwrap_err();
358        assert!(error.to_string().contains("a.md"), "{error}");
359        assert!(no_duplicate_destination(&operations[..1]).is_ok());
360    }
361}