Skip to main content

basis_tasks/
handle.rs

1//! The opaque `<workspace-key>/<uuid>` grammar a task is named by.
2//!
3//! Opaque deliberately: the two halves are an FNV-1a digest of the canonical
4//! workspace path and a v4 UUID, and nothing outside [`data_dir`](super::data_dir)
5//! should parse them apart. A handle is a capability — knowing one is what
6//! lets a caller act on the task it names — not a path to be built by hand.
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12use crate::{Error, data_dir::valid_task_handle};
13
14/// A durable task's handle: `<16 lowercase hex>/<32 lowercase hex>`.
15///
16/// Never becomes a filesystem path outside the data directory root — every
17/// place one is turned into a path validates the grammar first
18/// (`DataDir::agent_dir`) — and is stable for the task's whole life: `spawn`
19/// mints it once, and every other verb takes it back unchanged.
20#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
21pub struct TaskHandle(String);
22
23impl TaskHandle {
24    /// Parses a handle, refusing anything that does not fit the grammar.
25    ///
26    /// The refusal is deliberately generic — "not a task handle" rather than
27    /// a byte-by-byte diagnosis — because the grammar is opaque by design:
28    /// there is nothing more specific a caller should learn about *why* a
29    /// string is not one. The error carries `Error::invalid_reference`: a
30    /// malformed handle was never going to resolve, whatever else settles —
31    /// the same fact this crate reports for a handle from another
32    /// workspace.
33    pub fn parse(handle: impl Into<String>) -> Result<Self, Error> {
34        let handle = handle.into();
35        if valid_task_handle(&handle).is_some() {
36            Ok(Self(handle))
37        } else {
38            Err(Error::invalid_reference(format!(
39                "`{handle}` is not a task handle"
40            )))
41        }
42    }
43
44    pub fn as_str(&self) -> &str {
45        &self.0
46    }
47
48    /// The workspace-key half: which workspace's `agents/` directory this
49    /// task lives under.
50    pub(crate) fn key(&self) -> &str {
51        valid_task_handle(&self.0)
52            .expect("a TaskHandle is only ever constructed from a valid grammar")
53            .0
54    }
55}
56
57impl fmt::Display for TaskHandle {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        f.write_str(&self.0)
60    }
61}
62
63impl std::str::FromStr for TaskHandle {
64    type Err = Error;
65
66    fn from_str(handle: &str) -> Result<Self, Self::Err> {
67        Self::parse(handle)
68    }
69}
70
71impl AsRef<str> for TaskHandle {
72    fn as_ref(&self) -> &str {
73        &self.0
74    }
75}
76
77impl Serialize for TaskHandle {
78    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
79        serializer.serialize_str(&self.0)
80    }
81}
82
83impl<'de> Deserialize<'de> for TaskHandle {
84    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
85        let handle = String::deserialize(deserializer)?;
86        Self::parse(handle).map_err(serde::de::Error::custom)
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn a_well_formed_handle_round_trips() {
96        let text = format!("0123456789abcdef/{:032x}", 1);
97        let handle = TaskHandle::parse(text.clone()).expect("well-formed");
98        assert_eq!(handle.as_str(), text);
99        assert_eq!(handle.to_string(), text);
100        assert_eq!(handle.key(), "0123456789abcdef");
101    }
102
103    #[test]
104    fn a_malformed_handle_is_refused_generically() {
105        for bad in ["not-a-handle", "0123456789abcdef", "../../etc/passwd"] {
106            let error = TaskHandle::parse(bad).expect_err("refused");
107            assert!(error.to_string().contains("not a task handle"), "{error}");
108            assert!(
109                error.is_invalid_reference(),
110                "a malformed handle was never going to resolve: {error}"
111            );
112        }
113    }
114
115    #[test]
116    fn json_round_trips_as_a_bare_string() {
117        let text = format!("0123456789abcdef/{:032x}", 2);
118        let handle = TaskHandle::parse(text.clone()).unwrap();
119        let json = serde_json::to_string(&handle).unwrap();
120        assert_eq!(json, format!("\"{text}\""));
121        let back: TaskHandle = serde_json::from_str(&json).unwrap();
122        assert_eq!(back, handle);
123    }
124}