stakpak_shared/models/
context.rs1use serde::{Deserialize, Serialize};
2
3pub const MAX_CALLER_CONTEXT_ITEMS: usize = 32;
5pub const MAX_CALLER_CONTEXT_NAME_CHARS: usize = 256;
6pub const MAX_CALLER_CONTEXT_CONTENT_CHARS: usize = 50_000;
7pub const MAX_CALLER_CONTEXT_TOTAL_CHARS: usize = 500_000;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct CallerContextInput {
14 pub name: String,
15 pub content: String,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub priority: Option<String>,
18}
19
20pub fn validate_caller_context(inputs: &[CallerContextInput]) -> Result<(), String> {
25 if inputs.len() > MAX_CALLER_CONTEXT_ITEMS {
26 return Err(format!(
27 "context can include at most {} entries",
28 MAX_CALLER_CONTEXT_ITEMS
29 ));
30 }
31
32 let mut total_content_chars: usize = 0;
33
34 for input in inputs {
35 let raw_name_len = input.name.chars().count();
36 if raw_name_len > MAX_CALLER_CONTEXT_NAME_CHARS {
37 return Err(format!(
38 "context.name exceeds {} characters",
39 MAX_CALLER_CONTEXT_NAME_CHARS
40 ));
41 }
42
43 let raw_content_len = input.content.chars().count();
44 if raw_content_len > MAX_CALLER_CONTEXT_CONTENT_CHARS {
45 return Err(format!(
46 "context.content exceeds {} characters",
47 MAX_CALLER_CONTEXT_CONTENT_CHARS
48 ));
49 }
50
51 total_content_chars = total_content_chars.saturating_add(raw_content_len);
52 if total_content_chars > MAX_CALLER_CONTEXT_TOTAL_CHARS {
53 return Err(format!(
54 "total context exceeds {} characters",
55 MAX_CALLER_CONTEXT_TOTAL_CHARS
56 ));
57 }
58
59 let trimmed_name = input.name.trim();
60 let trimmed_content = input.content.trim();
61 if trimmed_name.is_empty() || trimmed_content.is_empty() {
62 continue; }
64 }
65
66 Ok(())
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 #[test]
74 fn validate_accepts_within_limits() {
75 let input = CallerContextInput {
76 name: "n".repeat(MAX_CALLER_CONTEXT_NAME_CHARS),
77 content: "x".repeat(MAX_CALLER_CONTEXT_CONTENT_CHARS),
78 priority: Some("high".to_string()),
79 };
80 assert!(validate_caller_context(&[input]).is_ok());
81 }
82
83 #[test]
84 fn validate_rejects_too_many_items() {
85 let inputs: Vec<_> = (0..MAX_CALLER_CONTEXT_ITEMS + 1)
86 .map(|i| CallerContextInput {
87 name: format!("ctx-{i}"),
88 content: "value".to_string(),
89 priority: None,
90 })
91 .collect();
92 assert!(validate_caller_context(&inputs).is_err());
93 }
94
95 #[test]
96 fn validate_rejects_oversized_name() {
97 let input = CallerContextInput {
98 name: "n".repeat(MAX_CALLER_CONTEXT_NAME_CHARS + 1),
99 content: "value".to_string(),
100 priority: None,
101 };
102 assert!(validate_caller_context(&[input]).is_err());
103 }
104
105 #[test]
106 fn validate_rejects_oversized_content() {
107 let input = CallerContextInput {
108 name: "ctx".to_string(),
109 content: "x".repeat(MAX_CALLER_CONTEXT_CONTENT_CHARS + 1),
110 priority: None,
111 };
112 assert!(validate_caller_context(&[input]).is_err());
113 }
114
115 #[test]
116 fn validate_rejects_total_content_over_limit() {
117 let inputs: Vec<_> = (0..11)
118 .map(|i| CallerContextInput {
119 name: format!("ctx-{i}"),
120 content: "x".repeat(MAX_CALLER_CONTEXT_CONTENT_CHARS),
121 priority: None,
122 })
123 .collect();
124
125 assert!(validate_caller_context(&inputs).is_err());
126 }
127
128 #[test]
129 fn validate_rejects_oversized_whitespace_only_name() {
130 let input = CallerContextInput {
131 name: " ".repeat(MAX_CALLER_CONTEXT_NAME_CHARS + 1),
132 content: "value".to_string(),
133 priority: None,
134 };
135 assert!(
136 validate_caller_context(&[input]).is_err(),
137 "raw name length must be enforced even if trimmed name is empty"
138 );
139 }
140
141 #[test]
142 fn validate_rejects_oversized_whitespace_only_content() {
143 let input = CallerContextInput {
144 name: "ctx".to_string(),
145 content: " ".repeat(MAX_CALLER_CONTEXT_CONTENT_CHARS + 1),
146 priority: None,
147 };
148 assert!(
149 validate_caller_context(&[input]).is_err(),
150 "raw content length must be enforced even if trimmed content is empty"
151 );
152 }
153
154 #[test]
155 fn validate_skips_small_whitespace_only_content() {
156 let input = CallerContextInput {
157 name: "ctx".to_string(),
158 content: " ".to_string(),
159 priority: None,
160 };
161 assert!(
162 validate_caller_context(&[input]).is_ok(),
163 "small whitespace-only content is skipped downstream"
164 );
165 }
166}