Skip to main content

made_core/value_objects/
task_description.rs

1//! [`TaskDescription`] value object.
2//!
3//! A free-form textual prompt submitted with a task. The domain does
4//! not interpret its contents (that is the job of agents and
5//! validators), but it does enforce basic size bounds so an unbounded
6//! payload cannot slip through the core.
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::DomainError;
13
14/// Soft upper bound. Large enough to hold a rich prompt (several
15/// thousand tokens) but small enough to reject obvious misuse.
16pub const MAX_TASK_DESCRIPTION_LEN: usize = 64 * 1024;
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(transparent)]
20pub struct TaskDescription(String);
21
22impl TaskDescription {
23    pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
24        let value = raw.into();
25        if value.trim().is_empty() {
26            return Err(DomainError::EmptyField {
27                field: "task_description",
28            });
29        }
30        if value.len() > MAX_TASK_DESCRIPTION_LEN {
31            return Err(DomainError::FieldTooLong {
32                field: "task_description",
33                actual: value.len(),
34                max: MAX_TASK_DESCRIPTION_LEN,
35            });
36        }
37        Ok(Self(value))
38    }
39
40    #[must_use]
41    pub fn as_str(&self) -> &str {
42        &self.0
43    }
44
45    #[must_use]
46    pub fn into_inner(self) -> String {
47        self.0
48    }
49}
50
51impl fmt::Display for TaskDescription {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        f.write_str(&self.0)
54    }
55}
56
57impl TryFrom<&str> for TaskDescription {
58    type Error = DomainError;
59    fn try_from(value: &str) -> Result<Self, Self::Error> {
60        Self::new(value)
61    }
62}
63
64impl TryFrom<String> for TaskDescription {
65    type Error = DomainError;
66    fn try_from(value: String) -> Result<Self, Self::Error> {
67        Self::new(value)
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn typical_prompt_is_accepted() {
77        assert_eq!(
78            TaskDescription::new("Summarize the alert payload.")
79                .unwrap()
80                .as_str(),
81            "Summarize the alert payload."
82        );
83    }
84
85    #[test]
86    fn whitespace_only_is_rejected() {
87        assert!(matches!(
88            TaskDescription::new("   \n\t").unwrap_err(),
89            DomainError::EmptyField {
90                field: "task_description"
91            }
92        ));
93    }
94
95    #[test]
96    fn overlong_is_rejected() {
97        let too_long = "a".repeat(MAX_TASK_DESCRIPTION_LEN + 1);
98        assert!(matches!(
99            TaskDescription::new(too_long).unwrap_err(),
100            DomainError::FieldTooLong { .. }
101        ));
102    }
103
104    #[test]
105    fn multiline_is_preserved_verbatim() {
106        let d = TaskDescription::new("line one\nline two").unwrap();
107        assert_eq!(d.as_str(), "line one\nline two");
108    }
109
110    #[test]
111    fn serde_is_transparent() {
112        let d = TaskDescription::new("hi").unwrap();
113        assert_eq!(serde_json::to_string(&d).unwrap(), "\"hi\"");
114    }
115}