SaylorTwift HF Staff commited on
Commit
d90101d
·
verified ·
1 Parent(s): 1851bae

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. crates/forge_domain/src/chat_request.rs +17 -0
  2. crates/forge_domain/src/command.rs +21 -0
  3. crates/forge_domain/src/console.rs +15 -0
  4. crates/forge_domain/src/conversation.rs +358 -0
  5. crates/forge_domain/src/conversation_html.rs +513 -0
  6. crates/forge_domain/src/conversation_style.css +313 -0
  7. crates/forge_domain/src/data_gen.rs +28 -0
  8. crates/forge_domain/src/env.rs +388 -0
  9. crates/forge_domain/src/error.rs +178 -0
  10. crates/forge_domain/src/event.rs +227 -0
  11. crates/forge_domain/src/file_operation.rs +25 -0
  12. crates/forge_domain/src/group_by_key.rs +28 -0
  13. crates/forge_domain/src/hook.rs +1136 -0
  14. crates/forge_domain/src/http_config.rs +284 -0
  15. crates/forge_domain/src/image.rs +30 -0
  16. crates/forge_domain/src/lib.rs +119 -0
  17. crates/forge_domain/src/max_tokens.rs +199 -0
  18. crates/forge_domain/src/mcp_servers.rs +50 -0
  19. crates/forge_domain/src/merge.rs +59 -0
  20. crates/forge_domain/src/message.rs +471 -0
  21. crates/forge_domain/src/message_pattern.rs +262 -0
  22. crates/forge_domain/src/model_config.rs +30 -0
  23. crates/forge_domain/src/node.rs +506 -0
  24. crates/forge_domain/src/provider.rs +1013 -0
  25. crates/forge_domain/src/shell.rs +14 -0
  26. crates/forge_domain/src/skill.rs +102 -0
  27. crates/forge_domain/src/snapshot.rs +148 -0
  28. crates/forge_domain/src/suggestion.rs +7 -0
  29. crates/forge_domain/src/temperature.rs +215 -0
  30. crates/forge_domain/src/tool_order.rs +436 -0
  31. crates/forge_domain/src/top_k.rs +194 -0
  32. crates/forge_domain/src/update.rs +34 -0
  33. crates/forge_domain/src/validation.rs +10 -0
  34. crates/forge_domain/src/workspace.rs +30 -0
  35. crates/forge_domain/src/xml.rs +168 -0
  36. crates/forge_domain/tests/test_stringified_tool_calls.rs +313 -0
  37. crates/forge_eventsource_stream/src/event.rs +16 -0
  38. crates/forge_eventsource_stream/src/event_stream.rs +560 -0
  39. crates/forge_eventsource_stream/src/lib.rs +41 -0
  40. crates/forge_eventsource_stream/src/parser.rs +124 -0
  41. crates/forge_eventsource_stream/src/traits.rs +19 -0
  42. crates/forge_eventsource_stream/src/utf8_stream.rs +175 -0
  43. crates/forge_fs/src/binary_detection.rs +160 -0
  44. crates/forge_fs/src/error.rs +25 -0
  45. crates/forge_fs/src/file_size.rs +17 -0
  46. crates/forge_fs/src/is_binary.rs +104 -0
  47. crates/forge_fs/src/lib.rs +36 -0
  48. crates/forge_fs/src/meta.rs +25 -0
  49. crates/forge_fs/src/read.rs +24 -0
  50. crates/forge_fs/src/read_range.rs +335 -0
crates/forge_domain/src/chat_request.rs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use derive_setters::Setters;
2
+ use serde::{Deserialize, Serialize};
3
+
4
+ use crate::{ConversationId, Event};
5
+
6
+ #[derive(Debug, Serialize, Deserialize, Clone, Setters)]
7
+ #[setters(into, strip_option)]
8
+ pub struct ChatRequest {
9
+ pub event: Event,
10
+ pub conversation_id: ConversationId,
11
+ }
12
+
13
+ impl ChatRequest {
14
+ pub fn new(content: Event, conversation_id: ConversationId) -> Self {
15
+ Self { event: content, conversation_id }
16
+ }
17
+ }
crates/forge_domain/src/command.rs ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use derive_setters::Setters;
2
+ use serde::Deserialize;
3
+
4
+ /// A user-defined command loaded from a Markdown file with YAML frontmatter.
5
+ ///
6
+ /// Commands are discovered from `.md` files in the forge commands directories
7
+ /// and made available as slash commands in the UI. The `name` and `description`
8
+ /// come from YAML frontmatter; the `prompt` is the Markdown body of the file.
9
+ #[derive(Debug, Clone, Default, Deserialize, Setters, PartialEq)]
10
+ #[setters(into, strip_option)]
11
+ pub struct Command {
12
+ /// The command name used to invoke it (e.g. `github-pr-description`).
13
+ #[serde(default)]
14
+ pub name: String,
15
+ /// Short description shown in the command list.
16
+ #[serde(default)]
17
+ pub description: String,
18
+ /// The prompt template body (Markdown content after the frontmatter).
19
+ #[serde(default, skip_serializing_if = "Option::is_none")]
20
+ pub prompt: Option<String>,
21
+ }
crates/forge_domain/src/console.rs ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::io;
2
+
3
+ /// Trait for synchronized output writing.
4
+ /// Provides two output channels (primary and error) with flush support.
5
+ /// Implementors must ensure thread-safe writes.
6
+ pub trait ConsoleWriter: Send + Sync {
7
+ /// Writes bytes to primary output.
8
+ fn write(&self, buf: &[u8]) -> io::Result<usize>;
9
+ /// Writes bytes to error output.
10
+ fn write_err(&self, buf: &[u8]) -> io::Result<usize>;
11
+ /// Flushes primary output.
12
+ fn flush(&self) -> io::Result<()>;
13
+ /// Flushes error output.
14
+ fn flush_err(&self) -> io::Result<()>;
15
+ }
crates/forge_domain/src/conversation.rs ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::str::FromStr;
2
+
3
+ use chrono::{DateTime, Utc};
4
+ use derive_more::derive::Display;
5
+ use derive_setters::Setters;
6
+ use serde::{Deserialize, Serialize};
7
+ use uuid::Uuid;
8
+
9
+ use crate::{Context, Error, Metrics, Result, TokenCount};
10
+
11
+ #[derive(Debug, Default, Display, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
12
+ #[serde(transparent)]
13
+ pub struct ConversationId(Uuid);
14
+
15
+ impl Copy for ConversationId {}
16
+
17
+ impl ConversationId {
18
+ pub fn generate() -> Self {
19
+ Self(Uuid::new_v4())
20
+ }
21
+
22
+ pub fn into_string(&self) -> String {
23
+ self.0.to_string()
24
+ }
25
+
26
+ pub fn parse(value: impl ToString) -> Result<Self> {
27
+ Ok(Self(
28
+ Uuid::parse_str(&value.to_string()).map_err(Error::ConversationId)?,
29
+ ))
30
+ }
31
+ }
32
+
33
+ impl FromStr for ConversationId {
34
+ type Err = Error;
35
+
36
+ fn from_str(s: &str) -> Result<Self> {
37
+ Self::parse(s)
38
+ }
39
+ }
40
+
41
+ #[derive(Debug, Setters, Serialize, Deserialize, Clone)]
42
+ #[setters(into)]
43
+ pub struct Conversation {
44
+ pub id: ConversationId,
45
+ pub title: Option<String>,
46
+ pub context: Option<Context>,
47
+ pub metrics: Metrics,
48
+ pub metadata: MetaData,
49
+ }
50
+
51
+ #[derive(Debug, Setters, Serialize, Deserialize, Clone)]
52
+ #[setters(into)]
53
+ pub struct MetaData {
54
+ pub created_at: DateTime<Utc>,
55
+ pub updated_at: Option<DateTime<Utc>>,
56
+ }
57
+
58
+ impl MetaData {
59
+ pub fn new(created_at: DateTime<Utc>) -> Self {
60
+ Self { created_at, updated_at: None }
61
+ }
62
+ }
63
+
64
+ impl Conversation {
65
+ pub fn new(id: ConversationId) -> Self {
66
+ let created_at = Utc::now();
67
+ let metrics = Metrics::default().started_at(created_at);
68
+ Self {
69
+ id,
70
+ metrics,
71
+ metadata: MetaData::new(created_at),
72
+ title: None,
73
+ context: None,
74
+ }
75
+ }
76
+ /// Creates a new conversation with a new conversation ID.
77
+ ///
78
+ /// This is a convenience constructor that automatically generates a unique
79
+ /// conversation ID, making it easy to create new conversations without
80
+ /// having to manually create the ID.
81
+ pub fn generate() -> Self {
82
+ Self::new(ConversationId::generate())
83
+ }
84
+
85
+ /// Generates an HTML representation of the conversation
86
+ ///
87
+ /// This method uses Handlebars to render the conversation as HTML
88
+ /// from the template file, including all agents, events, and variables.
89
+ ///
90
+ /// # Errors
91
+ /// - If the template file cannot be found or read
92
+ /// - If the Handlebars template registration fails
93
+ /// - If the template rendering fails
94
+ pub fn to_html(&self) -> String {
95
+ // Instead of using Handlebars, we now use our Element DSL
96
+ crate::conversation_html::render_conversation_html(self)
97
+ }
98
+
99
+ /// Generates an HTML representation with related agent conversations
100
+ ///
101
+ /// Creates a single HTML document containing the main conversation
102
+ /// and all related agent conversations with anchor links for navigation.
103
+ pub fn to_html_with_related(&self, related: &[Conversation]) -> String {
104
+ crate::conversation_html::render_conversation_html_with_related(self, related)
105
+ }
106
+
107
+ /// Returns a vector of user messages, selecting the first message from
108
+ /// each consecutive sequence of user messages.
109
+ pub fn first_user_messages(&self) -> Vec<&crate::ContextMessage> {
110
+ self.context
111
+ .as_ref()
112
+ .map(|ctx| ctx.first_user_messages())
113
+ .unwrap_or_default()
114
+ }
115
+
116
+ /// Returns the total token usage across all messages in the conversation.
117
+ ///
118
+ /// This is a convenience method that aggregates usage from the context,
119
+ /// if available.
120
+ pub fn accumulated_usage(&self) -> Option<crate::Usage> {
121
+ self.context.as_ref().and_then(|ctx| ctx.accumulate_usage())
122
+ }
123
+
124
+ pub fn usage(&self) -> Option<crate::Usage> {
125
+ self.context
126
+ .iter()
127
+ .flat_map(|ctx| ctx.messages.iter())
128
+ .flat_map(|msg| msg.usage.into_iter())
129
+ .last()
130
+ }
131
+
132
+ pub fn token_count(&self) -> Option<TokenCount> {
133
+ self.context.as_ref().map(|ctx| ctx.token_count())
134
+ }
135
+
136
+ pub fn accumulated_cost(&self) -> Option<f64> {
137
+ self.accumulated_usage().and_then(|usage| usage.cost)
138
+ }
139
+
140
+ /// Calculates the total cost including related conversations.
141
+ ///
142
+ /// This method sums the costs of the current conversation and all related
143
+ /// conversations provided. It's useful for computing the total cost of a
144
+ /// conversation tree when agent tools spawn child conversations.
145
+ ///
146
+ /// # Arguments
147
+ ///
148
+ /// * `conversations` - A slice of conversations to include in cost
149
+ /// calculation
150
+ pub fn total_cost(conversations: &[Conversation]) -> Option<f64> {
151
+ let costs: Vec<f64> = conversations
152
+ .iter()
153
+ .filter_map(|conv| conv.accumulated_cost())
154
+ .collect();
155
+
156
+ if costs.is_empty() {
157
+ return None;
158
+ }
159
+
160
+ Some(costs.iter().sum())
161
+ }
162
+
163
+ /// Returns the number of messages in the conversation context.
164
+ ///
165
+ /// Returns `0` if the context has not been initialized yet.
166
+ pub fn len(&self) -> usize {
167
+ self.context
168
+ .as_ref()
169
+ .map(|ctx| ctx.messages.len())
170
+ .unwrap_or(0)
171
+ }
172
+
173
+ /// Returns `true` if the conversation context has no messages.
174
+ pub fn is_empty(&self) -> bool {
175
+ self.len() == 0
176
+ }
177
+
178
+ /// Extracts all related conversation IDs from agent tool calls.
179
+ ///
180
+ /// This method scans through all tool results in the conversation's context
181
+ /// and collects conversation IDs from AI tool values, which are created
182
+ /// when agent tools are called and trigger new conversations.
183
+ pub fn related_conversation_ids(&self) -> Vec<ConversationId> {
184
+ self.context
185
+ .as_ref()
186
+ .map(|ctx| {
187
+ ctx.messages
188
+ .iter()
189
+ .filter_map(|msg| msg.as_tool_result())
190
+ .flat_map(|result| &result.output.values)
191
+ .filter_map(|value| {
192
+ if let crate::ToolValue::AI { conversation_id, .. } = value {
193
+ Some(conversation_id)
194
+ } else {
195
+ None
196
+ }
197
+ })
198
+ .copied()
199
+ .collect()
200
+ })
201
+ .unwrap_or_default()
202
+ }
203
+ }
204
+
205
+ #[cfg(test)]
206
+ mod tests {
207
+ use pretty_assertions::assert_eq;
208
+
209
+ use super::*;
210
+ use crate::{Context, ContextMessage, ToolOutput, ToolResult, ToolValue};
211
+
212
+ #[test]
213
+ fn test_related_conversation_ids_empty() {
214
+ let conversation = Conversation::generate();
215
+ let actual = conversation.related_conversation_ids();
216
+ assert_eq!(actual, vec![]);
217
+ }
218
+
219
+ #[test]
220
+ fn test_related_conversation_ids_with_agent_calls() {
221
+ let agent_conv_id_1 = ConversationId::generate();
222
+ let agent_conv_id_2 = ConversationId::generate();
223
+
224
+ let context = Context::default()
225
+ .add_message(ContextMessage::user("Test task", None))
226
+ .add_message(ContextMessage::assistant("Working on it", None, None, None))
227
+ .add_message(ContextMessage::Tool(ToolResult::new("agent_tool").output(
228
+ Ok(ToolOutput {
229
+ is_error: false,
230
+ values: vec![ToolValue::AI {
231
+ value: "Agent result".to_string(),
232
+ conversation_id: agent_conv_id_1,
233
+ }],
234
+ }),
235
+ )))
236
+ .add_message(ContextMessage::assistant("Continuing", None, None, None))
237
+ .add_message(ContextMessage::Tool(
238
+ ToolResult::new("another_agent").output(Ok(ToolOutput {
239
+ is_error: false,
240
+ values: vec![ToolValue::AI {
241
+ value: "Another agent result".to_string(),
242
+ conversation_id: agent_conv_id_2,
243
+ }],
244
+ })),
245
+ ));
246
+
247
+ let conversation = Conversation::generate().context(context);
248
+ let actual = conversation.related_conversation_ids();
249
+
250
+ assert_eq!(actual.len(), 2);
251
+ assert!(actual.contains(&agent_conv_id_1));
252
+ assert!(actual.contains(&agent_conv_id_2));
253
+ }
254
+
255
+ #[test]
256
+ fn test_related_conversation_ids_mixed_tool_results() {
257
+ let agent_conv_id = ConversationId::generate();
258
+
259
+ let context = Context::default()
260
+ .add_message(ContextMessage::user("Test task", None))
261
+ .add_message(ContextMessage::Tool(
262
+ ToolResult::new("regular_tool").output(Ok(ToolOutput::text("Regular result"))),
263
+ ))
264
+ .add_message(ContextMessage::Tool(ToolResult::new("agent_tool").output(
265
+ Ok(ToolOutput {
266
+ is_error: false,
267
+ values: vec![ToolValue::AI {
268
+ value: "Agent result".to_string(),
269
+ conversation_id: agent_conv_id,
270
+ }],
271
+ }),
272
+ )))
273
+ .add_message(ContextMessage::Tool(
274
+ ToolResult::new("another_regular_tool")
275
+ .output(Ok(ToolOutput::text("Another regular result"))),
276
+ ));
277
+
278
+ let conversation = Conversation::generate().context(context);
279
+ let actual = conversation.related_conversation_ids();
280
+
281
+ assert_eq!(actual, vec![agent_conv_id]);
282
+ }
283
+
284
+ #[test]
285
+ fn test_total_cost() {
286
+ use crate::{MessageEntry, Usage};
287
+
288
+ // Create main conversation with cost
289
+ // Create main conversation with cost
290
+ let main_usage = Usage { cost: Some(0.01), ..Usage::default() };
291
+ let main_entry: MessageEntry =
292
+ ContextMessage::assistant("Response", None, None, None).into();
293
+ let main_context = Context::default()
294
+ .add_message(ContextMessage::user("Test", None))
295
+ .add_entry(main_entry.usage(main_usage));
296
+
297
+ let main_conv = Conversation::generate().context(main_context);
298
+
299
+ // Create related conversations with costs
300
+ let related_usage_1 = Usage { cost: Some(0.02), ..Usage::default() };
301
+ let related_entry_1: MessageEntry =
302
+ ContextMessage::assistant("Result 1", None, None, None).into();
303
+ let related_context_1 = Context::default()
304
+ .add_message(ContextMessage::user("Task 1", None))
305
+ .add_entry(related_entry_1.usage(related_usage_1));
306
+
307
+ let related_conv_1 = Conversation::generate().context(related_context_1);
308
+
309
+ let related_usage_2 = Usage { cost: Some(0.03), ..Usage::default() };
310
+ let related_entry_2: MessageEntry =
311
+ ContextMessage::assistant("Result 2", None, None, None).into();
312
+ let related_context_2 = Context::default()
313
+ .add_message(ContextMessage::user("Task 2", None))
314
+ .add_entry(related_entry_2.usage(related_usage_2));
315
+ let related_conv_2 = Conversation::generate().context(related_context_2);
316
+
317
+ let actual = Conversation::total_cost(&[main_conv, related_conv_1, related_conv_2]);
318
+
319
+ // Check that cost is approximately 0.06 (accounting for floating point
320
+ // precision)
321
+ assert!(actual.is_some());
322
+ assert!((actual.unwrap() - 0.06).abs() < 0.0001);
323
+ }
324
+
325
+ #[test]
326
+ fn test_total_cost_no_costs() {
327
+ let main_conv = Conversation::generate();
328
+ let related_conv = Conversation::generate();
329
+
330
+ let actual = Conversation::total_cost(&[main_conv, related_conv]);
331
+ let expected = None;
332
+
333
+ assert_eq!(actual, expected);
334
+ }
335
+
336
+ #[test]
337
+ fn test_total_cost_partial_costs() {
338
+ use crate::{MessageEntry, Usage};
339
+
340
+ // Main conversation has no cost
341
+ let main_conv = Conversation::generate();
342
+
343
+ // Related conversation has cost
344
+ let related_usage = Usage { cost: Some(0.05), ..Usage::default() };
345
+ let related_entry: MessageEntry =
346
+ ContextMessage::assistant("Result", None, None, None).into();
347
+ let related_context = Context::default()
348
+ .add_message(ContextMessage::user("Task", None))
349
+ .add_entry(related_entry.usage(related_usage));
350
+
351
+ let related_conv = Conversation::generate().context(related_context);
352
+
353
+ let actual = Conversation::total_cost(&[main_conv, related_conv]);
354
+ let expected = Some(0.05);
355
+
356
+ assert_eq!(actual, expected);
357
+ }
358
+ }
crates/forge_domain/src/conversation_html.rs ADDED
@@ -0,0 +1,513 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use forge_template::Element;
2
+ use serde_json::to_string_pretty;
3
+
4
+ use crate::context::ContextMessage;
5
+ use crate::conversation::Conversation;
6
+
7
+ /// Renders a conversation as an HTML document
8
+ ///
9
+ /// Creates a complete HTML page displaying the conversation's information
10
+ /// including:
11
+ /// - Basic information (ID, title)
12
+ /// - Reasoning configuration
13
+ /// - Usage statistics (token counts and costs)
14
+ /// - Context messages with tool calls and reasoning details
15
+ /// - Available tools
16
+ /// - Error styling for tool call failures
17
+ ///
18
+ /// # Arguments
19
+ ///
20
+ /// * `conversation` - The conversation to render
21
+ pub fn render_conversation_html(conversation: &Conversation) -> String {
22
+ let c_title = format!(
23
+ "Title: {}",
24
+ conversation
25
+ .title
26
+ .clone()
27
+ .unwrap_or(conversation.id.to_string())
28
+ );
29
+ let html = Element::new("html")
30
+ .attr("lang", "en")
31
+ .append(
32
+ Element::new("head")
33
+ .append(Element::new("meta").attr("charset", "UTF-8"))
34
+ .append(
35
+ Element::new("meta")
36
+ .attr("name", "viewport")
37
+ .attr("content", "width=device-width, initial-scale=1.0"),
38
+ )
39
+ .append(Element::new("title").text(&c_title))
40
+ .append(Element::new("style").text(include_str!("conversation_style.css"))), // Includes tool-call-error styles
41
+ )
42
+ .append(
43
+ Element::new("body")
44
+ // Combined Information Table
45
+ .append(create_info_table(conversation))
46
+ // Conversation Context Section
47
+ .append(create_conversation_context_section(conversation))
48
+ // Tools Section
49
+ .append(create_tools_section(conversation)),
50
+ );
51
+
52
+ format!("<!DOCTYPE html>\n{}", html.render())
53
+ }
54
+
55
+ /// Renders a conversation with related agent conversations in a single HTML
56
+ /// document
57
+ ///
58
+ /// Creates a complete HTML page with the main conversation followed by
59
+ /// related agent conversations. Uses anchor links for navigation.
60
+ ///
61
+ /// # Arguments
62
+ ///
63
+ /// * `conversation` - The main conversation to render
64
+ /// * `related` - Related agent conversations to include
65
+ pub fn render_conversation_html_with_related(
66
+ conversation: &Conversation,
67
+ related: &[Conversation],
68
+ ) -> String {
69
+ let c_title = format!(
70
+ "Title: {}",
71
+ conversation
72
+ .title
73
+ .clone()
74
+ .unwrap_or(conversation.id.to_string())
75
+ );
76
+
77
+ let mut body = Element::new("body")
78
+ // Combined Information Table
79
+ .append(create_info_table(conversation))
80
+ // Conversation Context Section
81
+ .append(create_conversation_context_section(conversation))
82
+ // Tools Section
83
+ .append(create_tools_section(conversation));
84
+
85
+ // Add related conversations section
86
+ if !related.is_empty() {
87
+ body = body.append(Element::new("div.section").append(
88
+ Element::new("h2").text(format!("Related Agent Conversations ({})", related.len())),
89
+ ));
90
+
91
+ for related_conv in related {
92
+ let anchor_id = format!("conversation-{}", related_conv.id);
93
+ body = body.append(
94
+ Element::new("div.related-conversation")
95
+ .attr("id", &anchor_id)
96
+ .append(
97
+ Element::new("div.back-to-main").append(
98
+ Element::new("a")
99
+ .attr("href", "#")
100
+ .text("⬆ Back to main conversation"),
101
+ ),
102
+ )
103
+ .append(create_info_table(related_conv))
104
+ .append(create_conversation_context_section(related_conv))
105
+ .append(create_tools_section(related_conv)),
106
+ );
107
+ }
108
+ }
109
+
110
+ let html = Element::new("html")
111
+ .attr("lang", "en")
112
+ .append(
113
+ Element::new("head")
114
+ .append(Element::new("meta").attr("charset", "UTF-8"))
115
+ .append(
116
+ Element::new("meta")
117
+ .attr("name", "viewport")
118
+ .attr("content", "width=device-width, initial-scale=1.0"),
119
+ )
120
+ .append(Element::new("title").text(&c_title))
121
+ .append(Element::new("style").text(include_str!("conversation_style.css"))),
122
+ )
123
+ .append(body);
124
+
125
+ format!("<!DOCTYPE html>\n{}", html.render())
126
+ }
127
+
128
+ /// Creates a table row with a label and value
129
+ fn create_table_row(label: impl Into<String>, value: impl Into<String>) -> Element {
130
+ Element::new("tr")
131
+ .append(Element::new("th").text(label.into()))
132
+ .append(Element::new("td").text(value.into()))
133
+ }
134
+
135
+ /// Creates a combined information table with all conversation metadata
136
+ fn create_info_table(conversation: &Conversation) -> Element {
137
+ let section = Element::new("div.section").append(Element::new("h2").text("Conversation"));
138
+
139
+ let mut table = Element::new("table")
140
+ .append(create_table_row("ID", conversation.id.to_string()))
141
+ .append(create_table_row(
142
+ "Title",
143
+ conversation
144
+ .title
145
+ .clone()
146
+ .unwrap_or_else(|| "No title".to_string()),
147
+ ));
148
+
149
+ // Add reasoning configuration if available
150
+ if let Some(context) = &conversation.context {
151
+ if let Some(reasoning_config) = &context.reasoning {
152
+ let status = match reasoning_config.enabled {
153
+ Some(true) => "Enabled",
154
+ Some(false) => "Disabled",
155
+ None => "Not specified",
156
+ };
157
+ table = table
158
+ .append(create_table_row("Reasoning Status", status))
159
+ .append(create_table_row(
160
+ "Reasoning Effort",
161
+ format!("{:?}", reasoning_config.effort),
162
+ ));
163
+
164
+ if let Some(max_tokens) = reasoning_config.max_tokens {
165
+ table = table.append(create_table_row(
166
+ "Reasoning Max Tokens",
167
+ format!("{max_tokens:?}"),
168
+ ));
169
+ }
170
+ }
171
+
172
+ if let Some(max_tokens) = context.max_tokens {
173
+ table = table.append(create_table_row(
174
+ "Max Output Tokens",
175
+ format!("{:?}", max_tokens),
176
+ ))
177
+ }
178
+
179
+ // Add usage information if available
180
+ if let Some(usage) = context.accumulate_usage() {
181
+ let cache_percentage = if *usage.prompt_tokens > 0 {
182
+ (*usage.cached_tokens as f64 / *usage.prompt_tokens as f64 * 100.0) as usize
183
+ } else {
184
+ 0
185
+ };
186
+
187
+ let cached_display = if cache_percentage > 0 {
188
+ format!("{} [{}%]", usage.cached_tokens, cache_percentage)
189
+ } else {
190
+ format!("{}", usage.cached_tokens)
191
+ };
192
+
193
+ table = table
194
+ .append(create_table_row(
195
+ "Input Tokens",
196
+ format!("{}", usage.prompt_tokens),
197
+ ))
198
+ .append(create_table_row("Cached Tokens", cached_display))
199
+ .append(create_table_row(
200
+ "Output Tokens",
201
+ format!("{}", usage.completion_tokens),
202
+ ))
203
+ .append(create_table_row(
204
+ "Total Tokens",
205
+ format!("{}", usage.total_tokens),
206
+ ));
207
+
208
+ if let Some(cost) = usage.cost {
209
+ table = table.append(create_table_row("Cost", format!("${:.4}", cost)));
210
+ }
211
+ }
212
+ }
213
+
214
+ section.append(table)
215
+ }
216
+
217
+ /// Creates a tools section displaying all available tools
218
+ fn create_tools_section(conversation: &Conversation) -> Element {
219
+ let section = Element::new("div.section").append(Element::new("h2").text("Tools"));
220
+
221
+ if let Some(context) = &conversation.context {
222
+ if !context.tools.is_empty() {
223
+ let tools_elm =
224
+ Element::new("div.tools-section").append(context.tools.iter().map(|tool| {
225
+ Element::new("details.message-card.message-tool")
226
+ .append(
227
+ Element::new("summary").append(Element::span(tool.name.to_string())),
228
+ )
229
+ .append(
230
+ Element::new("div.main-content")
231
+ .append(Element::new("p").append(
232
+ Element::new("strong").text(tool.description.to_string()),
233
+ ))
234
+ .append(Element::new("pre").text(
235
+ to_string_pretty(&tool.input_schema).unwrap_or_default(),
236
+ )),
237
+ )
238
+ }));
239
+ section.append(tools_elm)
240
+ } else {
241
+ section.append(Element::new("p").text("No tools available"))
242
+ }
243
+ } else {
244
+ section.append(Element::new("p").text("No tools available"))
245
+ }
246
+ }
247
+
248
+ /// Creates a usage information section for a message
249
+ fn create_message_usage_section(usage: &crate::message::Usage) -> Element {
250
+ let cache_percentage = if *usage.prompt_tokens > 0 {
251
+ (*usage.cached_tokens as f64 / *usage.prompt_tokens as f64 * 100.0) as usize
252
+ } else {
253
+ 0
254
+ };
255
+
256
+ let cached_display = if cache_percentage > 0 {
257
+ format!("{} [{}%]", usage.cached_tokens, cache_percentage)
258
+ } else {
259
+ format!("{}", usage.cached_tokens)
260
+ };
261
+
262
+ let mut usage_div = Element::new("span")
263
+ .append(Element::new("strong").text("📊 Usage {"))
264
+ .append(
265
+ Element::new("span")
266
+ .append(
267
+ Element::new("span.usage-item").text(format!("input: {}", usage.prompt_tokens)),
268
+ )
269
+ .append(Element::new("span.usage-item").text(format!("cached: {}", cached_display)))
270
+ .append(
271
+ Element::new("span.usage-item")
272
+ .text(format!("output: {}", usage.completion_tokens)),
273
+ )
274
+ .append(
275
+ Element::new("span.usage-item").text(format!("total: {}", usage.total_tokens)),
276
+ ),
277
+ )
278
+ .append(Element::new("strong").text("}"));
279
+
280
+ if let Some(cost) = usage.cost {
281
+ usage_div = usage_div
282
+ .append(Element::new("span.usage-item.usage-cost").text(format!("Cost: ${:.4}", cost)));
283
+ }
284
+
285
+ usage_div
286
+ }
287
+
288
+ fn create_conversation_context_section(conversation: &Conversation) -> Element {
289
+ let section = Element::new("div.section").append(Element::new("h2").text("Messages"));
290
+
291
+ // Add context if available
292
+ if let Some(context) = &conversation.context {
293
+ let context_messages = Element::new("div.context-section").append(
294
+ context.messages.iter().map(|message_entry| {
295
+ match &**message_entry {
296
+ ContextMessage::Text(content_message) => {
297
+ // Convert role to lowercase for the class
298
+ let role_lowercase = content_message.role.to_string().to_lowercase();
299
+
300
+ let mut header =
301
+ Element::new("summary").text(format!("{}", content_message.role));
302
+
303
+ if let Some(model) = &content_message.model {
304
+ header = header
305
+ .append(Element::new("strong").text(" 🤖 model:"))
306
+ .append(Element::new("span").text(model));
307
+ }
308
+
309
+ // Add usage information
310
+ if let Some(usage) = &message_entry.usage {
311
+ header = header.append(create_message_usage_section(usage))
312
+ }
313
+
314
+ // Add reasoning indicator if reasoning details are present
315
+ let has_reasoning = content_message.reasoning_details.as_ref().is_some_and(|d| !d.is_empty())
316
+ || content_message.thought_signature.is_some();
317
+ if has_reasoning
318
+ {
319
+ header = header.append(
320
+ Element::new("span.reasoning-indicator").text(" 🧠 Reasoning"),
321
+ );
322
+ }
323
+
324
+ let message_elm =
325
+ Element::new(format!("details.message-card.message-{role_lowercase}"))
326
+ .append(header);
327
+
328
+ // Add thought signature
329
+ let mut message_elm = if let Some(sig) = &content_message.thought_signature {
330
+ message_elm.append(
331
+ Element::new("div.thought-signature")
332
+ .append(Element::new("strong").text("💭 Thought Signature: "))
333
+ .append(Element::new("pre").text(sig)),
334
+ )
335
+ } else {
336
+ message_elm
337
+ };
338
+
339
+ // Add reasoning details
340
+ message_elm = if let Some(reasoning_details) =
341
+ &content_message.reasoning_details
342
+ {
343
+ if !reasoning_details.is_empty() {
344
+ message_elm.append(Element::new("div.reasoning-section").append(
345
+ reasoning_details.iter().map(|reasoning_detail| {
346
+ if let Some(text) = &reasoning_detail.text {
347
+ Element::new("div.reasoning-content")
348
+ .append(
349
+ Element::new("strong").text("🧠 Reasoning: "),
350
+ )
351
+ .append(Element::new("pre").text(text))
352
+ } else {
353
+ Element::new("div")
354
+ }
355
+ }),
356
+ ))
357
+ } else {
358
+ message_elm
359
+ }
360
+ } else {
361
+ message_elm
362
+ };
363
+
364
+ // Add main content
365
+ let message_elm = message_elm.append(
366
+ Element::new("div.main-content")
367
+ .append(Element::new("pre").text(&content_message.content)),
368
+ );
369
+
370
+ // Add tool calls if any
371
+
372
+ if let Some(tool_calls) = &content_message.tool_calls {
373
+ if !tool_calls.is_empty() {
374
+ message_elm.append(Element::new("div").append(
375
+ tool_calls.iter().map(|tool_call| {
376
+ Element::new("div.tool-call")
377
+ .append(
378
+ Element::new("p").append(
379
+ Element::new("strong")
380
+ .text(tool_call.name.to_string()),
381
+ ),
382
+ )
383
+ .append(tool_call.call_id.as_ref().map(|call_id| {
384
+ Element::new("p")
385
+ .append(Element::new("strong").text("ID: "))
386
+ .text(call_id.as_str())
387
+ }))
388
+ .append(
389
+ Element::new("p").append(
390
+ Element::new("strong").text("Arguments: "),
391
+ ),
392
+ )
393
+ .append(
394
+ Element::new("pre").text(
395
+ to_string_pretty(&tool_call.arguments)
396
+ .unwrap_or_default(),
397
+ ),
398
+ )
399
+ }),
400
+ ))
401
+ } else {
402
+ message_elm
403
+ }
404
+ } else {
405
+ message_elm
406
+ }
407
+ }
408
+ ContextMessage::Tool(tool_result) => {
409
+ // Tool Message - apply error styling if the tool result is an error
410
+ let message_class = if tool_result.output.is_error {
411
+ "details.message-card.message-tool.tool-call-error"
412
+ } else {
413
+ "details.message-card.message-tool"
414
+ };
415
+
416
+ Element::new(message_class)
417
+ .append(
418
+ Element::new("summary")
419
+ .append(Element::new("strong").text("Tool Result: "))
420
+ .append(Element::span(tool_result.name.as_str())),
421
+ )
422
+ .append(Element::new("div.main-content").append(
423
+ tool_result.output.values.iter().filter_map(|value| {
424
+ match value {
425
+ crate::ToolValue::Text(text) => Some(
426
+ Element::new("div")
427
+ .append(Element::new("pre").text(text)),
428
+ ),
429
+ crate::ToolValue::Image(image) => {
430
+ Some(Element::new("img").attr("src", image.url()))
431
+ }
432
+ crate::ToolValue::Empty => None,
433
+ crate::ToolValue::AI { value, conversation_id } => {
434
+ // Use anchor link to navigate within the same HTML
435
+ let anchor_id = format!("conversation-{}", conversation_id);
436
+ Some(
437
+ Element::new("div.agent-conversation")
438
+ .append(
439
+ Element::new("p")
440
+ .append(Element::new("strong").text("🤖 Agent Conversation: "))
441
+ .append(
442
+ Element::new("a")
443
+ .attr("href", format!("#{}", anchor_id))
444
+ .attr("title", "Click to view agent conversation")
445
+ .text(conversation_id.to_string())
446
+ )
447
+ )
448
+ .append(Element::new("pre").text(value)),
449
+ )
450
+ },
451
+ }
452
+ }),
453
+ ))
454
+ }
455
+ ContextMessage::Image(image) => {
456
+ // Image message
457
+ Element::new("div.message-card.message-user")
458
+ .append(Element::new("strong").text("Image Attachment"))
459
+ .append(Element::new("img").attr("src", image.url()))
460
+ }
461
+ }
462
+ }),
463
+ );
464
+
465
+ // Create tool choice section if available
466
+ let context_elm = if let Some(tool_choice) = &context.tool_choice {
467
+ context_messages
468
+ .append(Element::new("strong").text("Tool Choice"))
469
+ .append(Element::new("div.tool-choice").append(
470
+ Element::new("pre").text(to_string_pretty(tool_choice).unwrap_or_default()),
471
+ ))
472
+ } else {
473
+ context_messages
474
+ };
475
+
476
+ // Add temperature if available
477
+ let context_elm = if let Some(temperature) = context.temperature {
478
+ context_elm.append(
479
+ Element::new("p")
480
+ .append(Element::new("strong").text("Temperature: "))
481
+ .text(format!("{temperature}")),
482
+ )
483
+ } else {
484
+ context_elm
485
+ };
486
+
487
+ section.append(context_elm)
488
+ } else {
489
+ section.append(Element::new("p").text("No context available"))
490
+ }
491
+ }
492
+
493
+ #[cfg(test)]
494
+ mod tests {
495
+ use forge_test_kit::json_fixture;
496
+
497
+ use super::*;
498
+
499
+ #[tokio::test]
500
+ async fn test_render_conversation_html_snapshot() {
501
+ // Load the conversation from the fixture file
502
+ let conversation: Conversation = json_fixture!("tests/fixtures/conversation.json").await;
503
+
504
+ // Render the HTML
505
+ let html = render_conversation_html(&conversation);
506
+
507
+ // Convert HTML string to bytes for binary snapshot
508
+ let html_bytes = html.into_bytes();
509
+
510
+ // Binary snapshot with exact .html extension
511
+ insta::assert_binary_snapshot!("conversation.html", html_bytes);
512
+ }
513
+ }
crates/forge_domain/src/conversation_style.css ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ font-family: "JetBrainsMono Nerd Font", "JetBrains Mono", "Fira Code", "Cascadia Code", "Consolas", "Menlo", "Monaco", monospace;
3
+ line-height: 1.6;
4
+ color: #333;
5
+ font-size: 1em;
6
+ max-width: 1200px;
7
+ margin: 0 auto;
8
+ }
9
+
10
+ summary {
11
+ cursor: pointer;
12
+ }
13
+
14
+ h1,
15
+ h2,
16
+ h3 {
17
+ color: #2c3e50;
18
+ }
19
+
20
+ h1 {
21
+ border-bottom: 2px solid #eee;
22
+ padding-bottom: 10px;
23
+ }
24
+
25
+ .section {
26
+ padding: 20px;
27
+ background-color: #f9f9f9;
28
+ border: 1px solid #eee;
29
+ }
30
+
31
+ .agent,
32
+ .event,
33
+ .variable {
34
+ padding: 15px;
35
+ background-color: white;
36
+ border: 1px solid #ddd;
37
+ }
38
+
39
+ .agent-header,
40
+ .event-header {
41
+ display: flex;
42
+ justify-content: space-between;
43
+ padding-bottom: 5px;
44
+ border-bottom: 1px solid #eee;
45
+ }
46
+
47
+ pre {
48
+ padding: 10px;
49
+ overflow-x: auto;
50
+ }
51
+
52
+ code, pre {
53
+ font-family: "JetBrainsMono Nerd Font", "JetBrains Mono", "Fira Code", "Cascadia Code", "Consolas", "Menlo", "Monaco", monospace;
54
+ }
55
+
56
+ table {
57
+ width: 100%;
58
+ border-collapse: collapse;
59
+ }
60
+
61
+ th,
62
+ td {
63
+ padding: 8px;
64
+ text-align: left;
65
+ }
66
+
67
+ th {
68
+ background-color: #f2f2f2;
69
+ font-weight: 600;
70
+ width: 30%;
71
+ }
72
+
73
+ td {
74
+ background-color: #ffffff;
75
+ }
76
+
77
+ .section table {
78
+ margin-top: 10px;
79
+ border: 1px solid #ddd;
80
+ }
81
+
82
+ .section table th {
83
+ border-bottom: 1px solid #ddd;
84
+ }
85
+
86
+ .section table tr:not(:last-child) td {
87
+ border-bottom: 1px solid #eee;
88
+ }
89
+
90
+ .message-card {
91
+ padding: 15px;
92
+ border: 1px solid #ddd;
93
+ }
94
+
95
+ .message-system {
96
+ background-color: #f8f9fa;
97
+ }
98
+
99
+ .message-user {
100
+ background-color: #e9f5ff;
101
+ }
102
+
103
+ .message-assistant {
104
+ background-color: #f0f7e6;
105
+ }
106
+
107
+ .message-tool {
108
+ background-color: #fff8e6;
109
+ }
110
+
111
+ .tool-call,
112
+ .tool-result {
113
+ background-color: #f5f5f5;
114
+ }
115
+
116
+ .tool-choice {
117
+ padding: 5px 10px;
118
+ background-color: #eef;
119
+ display: inline-block;
120
+ }
121
+
122
+ img {
123
+ max-width: 100%;
124
+ }
125
+
126
+ /* Reasoning styles */
127
+ .reasoning-section {
128
+ margin: 12px 0;
129
+ padding: 16px;
130
+ background-color: #f0f4f8;
131
+ border-left: 4px solid #7c3aed;
132
+ }
133
+
134
+ .reasoning-content {
135
+ margin-bottom: 12px;
136
+ }
137
+
138
+ .reasoning-content strong {
139
+ color: #7c3aed;
140
+ font-weight: 600;
141
+ }
142
+
143
+ .reasoning-content pre {
144
+ margin-top: 8px;
145
+ padding: 12px;
146
+ background-color: #faf5ff;
147
+ border: 1px solid #e9d5ff;
148
+ white-space: pre-wrap;
149
+ word-wrap: break-word;
150
+ font-size: 0.95em;
151
+ color: #6b7280;
152
+ font-style: italic;
153
+ line-height: 1.6;
154
+ }
155
+
156
+ .main-content {
157
+ margin: 12px 0;
158
+ }
159
+
160
+ .main-content strong {
161
+ color: #1f2937;
162
+ font-weight: 600;
163
+ }
164
+
165
+ .main-content pre {
166
+ margin-top: 8px;
167
+ padding: 12px;
168
+ background-color: #ffffff;
169
+ border: 1px solid #e5e7eb;
170
+ white-space: pre-wrap;
171
+ word-wrap: break-word;
172
+ font-size: 0.95em;
173
+ color: #1f2937;
174
+ line-height: 1.6;
175
+ }
176
+
177
+ .reasoning-indicator {
178
+ margin-left: 8px;
179
+ color: #7c3aed;
180
+ font-size: 0.9em;
181
+ }
182
+
183
+ /* Usage Section Styles */
184
+ .usage-section {
185
+ background-color: #f8f9fa;
186
+ border-left: 3px solid #6c757d;
187
+ padding: 8px 12px;
188
+ margin-top: 12px;
189
+ font-size: 0.9em;
190
+ }
191
+
192
+ .usage-section strong {
193
+ display: block;
194
+ margin-bottom: 6px;
195
+ color: #495057;
196
+ }
197
+
198
+ .usage-details {
199
+ display: flex;
200
+ flex-wrap: wrap;
201
+ gap: 12px;
202
+ }
203
+
204
+ .usage-item {
205
+ color: #6c757d;
206
+ font-weight: 500;
207
+ }
208
+
209
+ .usage-cost {
210
+ color: #28a745;
211
+ font-weight: 600;
212
+ }
213
+
214
+ /* Tool Call Error Styles */
215
+ .tool-call-error {
216
+ background-color: #fff5f5;
217
+ border-left: 4px solid #ef4444;
218
+ padding: 16px;
219
+ margin: 12px 0;
220
+ border-radius: 4px;
221
+ }
222
+
223
+ .tool-call-error .error-cause {
224
+ color: #dc2626;
225
+ font-weight: 600;
226
+ margin-bottom: 8px;
227
+ }
228
+
229
+ .tool-call-error .error-reflection {
230
+ background-color: #fee2e2;
231
+ padding: 12px;
232
+ border-radius: 4px;
233
+ margin-top: 8px;
234
+ font-size: 0.9em;
235
+ color: #7f1d1d;
236
+ }
237
+
238
+ /* Agent Conversation Link Styles */
239
+ .agent-conversation {
240
+ background-color: #f0f9ff;
241
+ border-left: 4px solid #3b82f6;
242
+ padding: 16px;
243
+ margin: 12px 0;
244
+ border-radius: 4px;
245
+ }
246
+
247
+ .agent-conversation p {
248
+ margin: 0 0 12px 0;
249
+ }
250
+
251
+ .agent-conversation strong {
252
+ color: #1e40af;
253
+ font-weight: 600;
254
+ }
255
+
256
+ .agent-conversation a {
257
+ color: #2563eb;
258
+ text-decoration: none;
259
+ font-weight: 600;
260
+ padding: 2px 6px;
261
+ background-color: #dbeafe;
262
+ border-radius: 3px;
263
+ transition: background-color 0.2s;
264
+ }
265
+
266
+ .agent-conversation a:hover {
267
+ background-color: #bfdbfe;
268
+ text-decoration: underline;
269
+ }
270
+
271
+ .agent-conversation pre {
272
+ margin-top: 8px;
273
+ padding: 12px;
274
+ background-color: #ffffff;
275
+ border: 1px solid #e0f2fe;
276
+ white-space: pre-wrap;
277
+ word-wrap: break-word;
278
+ font-size: 0.95em;
279
+ color: #1e293b;
280
+ line-height: 1.6;
281
+ }
282
+
283
+ /* Related Conversation Styles */
284
+ .related-conversation {
285
+ margin-top: 40px;
286
+ padding-top: 20px;
287
+ border-top: 3px solid #3b82f6;
288
+ scroll-margin-top: 20px;
289
+ }
290
+
291
+ .back-to-main {
292
+ margin-bottom: 20px;
293
+ padding: 10px;
294
+ background-color: #f0f9ff;
295
+ border-radius: 4px;
296
+ text-align: center;
297
+ }
298
+
299
+ .back-to-main a {
300
+ color: #2563eb;
301
+ text-decoration: none;
302
+ font-weight: 600;
303
+ padding: 8px 16px;
304
+ background-color: #dbeafe;
305
+ border-radius: 4px;
306
+ display: inline-block;
307
+ transition: background-color 0.2s;
308
+ }
309
+
310
+ .back-to-main a:hover {
311
+ background-color: #bfdbfe;
312
+ text-decoration: none;
313
+ }
crates/forge_domain/src/data_gen.rs ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::path::PathBuf;
2
+
3
+ use derive_setters::Setters;
4
+ use serde::{Deserialize, Serialize};
5
+
6
+ /// Parameters for data generation operations
7
+ ///
8
+ /// This struct encapsulates the configuration parameters needed for generating
9
+ /// data in various contexts. It provides control over the amount of data to
10
+ /// generate, formatting options, and other generation-specific settings.
11
+ #[derive(Debug, Clone, Serialize, Deserialize, Setters, PartialEq, fake::Dummy)]
12
+ #[setters(into, strip_option)]
13
+ pub struct DataGenerationParameters {
14
+ /// Path to input JSONL file for data generation
15
+ pub input: PathBuf,
16
+
17
+ /// Path to JSON schema file for LLM tool definition
18
+ pub schema: PathBuf,
19
+
20
+ /// Path to Handlebars template file for system prompt
21
+ pub system_prompt: Option<PathBuf>,
22
+
23
+ /// Path to Handlebars template file for user prompt
24
+ pub user_prompt: Option<PathBuf>,
25
+
26
+ /// Maximum number of concurrent LLM requests
27
+ pub concurrency: usize,
28
+ }
crates/forge_domain/src/env.rs ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::hash::{DefaultHasher, Hash, Hasher};
2
+ use std::path::PathBuf;
3
+
4
+ use derive_more::Display;
5
+ use derive_setters::Setters;
6
+ use serde::{Deserialize, Serialize};
7
+
8
+ use crate::{Effort, ModelConfig};
9
+
10
+ /// All discrete mutations that can be applied to the application configuration.
11
+ ///
12
+ /// Instead of replacing the entire config, callers describe exactly which field
13
+ /// they want to change. Implementations receive a list of operations, apply
14
+ /// each in order, and persist the result atomically.
15
+ #[derive(Debug, Clone, PartialEq)]
16
+ pub enum ConfigOperation {
17
+ /// Set the active session provider and model atomically.
18
+ ///
19
+ /// When the provider differs from the current session provider the entire
20
+ /// session (provider + model) is replaced atomically. When they match only
21
+ /// the model field is updated.
22
+ SetSessionConfig(ModelConfig),
23
+ /// Set the commit-message generation configuration.
24
+ ///
25
+ /// `None` clears the commit configuration so the active session
26
+ /// provider/model is used for commit message generation.
27
+ SetCommitConfig(Option<ModelConfig>),
28
+ /// Set the shell-command suggestion configuration.
29
+ SetSuggestConfig(ModelConfig),
30
+ /// Set the reasoning effort level for all agents.
31
+ SetReasoningEffort(Effort),
32
+ }
33
+
34
+ const VERSION: &str = match option_env!("APP_VERSION") {
35
+ Some(val) => val,
36
+ None => env!("CARGO_PKG_VERSION"),
37
+ };
38
+
39
+ /// Represents the minimal runtime environment in which the application is
40
+ /// running.
41
+ ///
42
+ /// Contains only the five fields that cannot be sourced from [`ForgeConfig`]:
43
+ /// `os`, `cwd`, `home`, `shell`, and `base_path`. All configuration
44
+ /// values previously carried here are now accessed through
45
+ /// `EnvironmentInfra::get_config()`.
46
+ #[derive(Debug, Setters, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)]
47
+ #[serde(rename_all = "camelCase")]
48
+ #[setters(strip_option)]
49
+ pub struct Environment {
50
+ /// The operating system of the environment.
51
+ pub os: String,
52
+ /// The current working directory.
53
+ pub cwd: PathBuf,
54
+ /// The home directory.
55
+ pub home: Option<PathBuf>,
56
+ /// The shell being used.
57
+ pub shell: String,
58
+ /// The base path relative to which everything else is stored.
59
+ pub base_path: PathBuf,
60
+ }
61
+
62
+ impl Environment {
63
+ pub fn log_path(&self) -> PathBuf {
64
+ self.base_path.join("logs")
65
+ }
66
+
67
+ /// Returns the history file path.
68
+ ///
69
+ /// # Arguments
70
+ /// * `custom_path` - An optional custom path sourced from
71
+ /// `ForgeConfig::custom_history_path`. When present it overrides the
72
+ /// default location inside `base_path`.
73
+ pub fn history_path(&self, custom_path: Option<&PathBuf>) -> PathBuf {
74
+ custom_path
75
+ .cloned()
76
+ .unwrap_or(self.base_path.join(".forge_history"))
77
+ }
78
+ pub fn snapshot_path(&self) -> PathBuf {
79
+ self.base_path.join("snapshots")
80
+ }
81
+ pub fn mcp_user_config(&self) -> PathBuf {
82
+ self.base_path.join(".mcp.json")
83
+ }
84
+
85
+ /// Returns the path where the MCP trust store is persisted across restarts.
86
+ pub fn mcp_trust_path(&self) -> PathBuf {
87
+ self.base_path.join(".mcp_trust.json")
88
+ }
89
+
90
+ pub fn agent_path(&self) -> PathBuf {
91
+ self.base_path.join("agents")
92
+ }
93
+ pub fn agent_cwd_path(&self) -> PathBuf {
94
+ self.cwd.join(".forge/agents")
95
+ }
96
+
97
+ pub fn permissions_path(&self) -> PathBuf {
98
+ self.base_path.join("permissions.yaml")
99
+ }
100
+
101
+ pub fn mcp_local_config(&self) -> PathBuf {
102
+ self.cwd.join(".mcp.json")
103
+ }
104
+
105
+ pub fn version(&self) -> String {
106
+ VERSION.to_string()
107
+ }
108
+
109
+ pub fn app_config(&self) -> PathBuf {
110
+ self.base_path.join(".config.json")
111
+ }
112
+
113
+ pub fn database_path(&self) -> PathBuf {
114
+ self.base_path.join(".forge.db")
115
+ }
116
+
117
+ /// Returns the path to the cache directory
118
+ pub fn cache_dir(&self) -> PathBuf {
119
+ self.base_path.join("cache")
120
+ }
121
+
122
+ /// Returns the global skills directory path (~/forge/skills)
123
+ pub fn global_skills_path(&self) -> PathBuf {
124
+ self.base_path.join("skills")
125
+ }
126
+
127
+ /// Returns the agents skills directory path (~/.agents/skills)
128
+ ///
129
+ /// Returns `None` when the home directory cannot be determined.
130
+ pub fn agents_skills_path(&self) -> Option<PathBuf> {
131
+ self.home.as_ref().map(|home| home.join(".agents/skills"))
132
+ }
133
+
134
+ /// Returns the project-local skills directory path (.forge/skills)
135
+ pub fn local_skills_path(&self) -> PathBuf {
136
+ self.cwd.join(".forge/skills")
137
+ }
138
+
139
+ /// Returns the global commands directory path (base_path/commands)
140
+ pub fn command_path(&self) -> PathBuf {
141
+ self.base_path.join("commands")
142
+ }
143
+
144
+ /// Returns the project-local commands directory path (.forge/commands)
145
+ pub fn command_path_local(&self) -> PathBuf {
146
+ self.cwd.join(".forge/commands")
147
+ }
148
+
149
+ /// Returns the global AGENTS.md path (base_path/AGENTS.md)
150
+ pub fn global_agentsmd_path(&self) -> PathBuf {
151
+ self.base_path.join("AGENTS.md")
152
+ }
153
+
154
+ /// Returns the project-local AGENTS.md path (cwd/AGENTS.md)
155
+ pub fn local_agentsmd_path(&self) -> PathBuf {
156
+ self.cwd.join("AGENTS.md")
157
+ }
158
+
159
+ /// Returns the plans directory path relative to the current working
160
+ /// directory (cwd/plans)
161
+ pub fn plans_path(&self) -> PathBuf {
162
+ self.cwd.join("plans")
163
+ }
164
+
165
+ /// Returns the path to the custom provider configuration file
166
+ /// (base_path/provider.json)
167
+ pub fn provider_config_path(&self) -> PathBuf {
168
+ self.base_path.join("provider.json")
169
+ }
170
+
171
+ /// Returns the path to the credentials file where provider API keys are
172
+ /// stored
173
+ pub fn credentials_path(&self) -> PathBuf {
174
+ self.base_path.join(".credentials.json")
175
+ }
176
+
177
+ pub fn workspace_hash(&self) -> WorkspaceHash {
178
+ let mut hasher = DefaultHasher::default();
179
+ self.cwd.hash(&mut hasher);
180
+
181
+ WorkspaceHash(hasher.finish())
182
+ }
183
+ }
184
+
185
+ #[derive(Clone, Copy, Display)]
186
+ pub struct WorkspaceHash(u64);
187
+ impl WorkspaceHash {
188
+ pub fn new(id: u64) -> Self {
189
+ WorkspaceHash(id)
190
+ }
191
+
192
+ pub fn id(&self) -> u64 {
193
+ self.0
194
+ }
195
+ }
196
+
197
+ #[cfg(test)]
198
+ mod tests {
199
+ use fake::{Fake, Faker};
200
+ use pretty_assertions::assert_eq;
201
+
202
+ use super::*;
203
+
204
+ #[test]
205
+ fn test_agent_cwd_path() {
206
+ let fixture: Environment = Faker.fake();
207
+ let fixture = fixture.cwd(PathBuf::from("/current/working/dir"));
208
+
209
+ let actual = fixture.agent_cwd_path();
210
+ let expected = PathBuf::from("/current/working/dir/.forge/agents");
211
+
212
+ assert_eq!(actual, expected);
213
+ }
214
+
215
+ #[test]
216
+ fn test_agent_cwd_path_independent_from_agent_path() {
217
+ let fixture: Environment = Faker.fake();
218
+ let fixture = fixture
219
+ .cwd(PathBuf::from("/different/current/dir"))
220
+ .base_path(PathBuf::from("/completely/different/base"));
221
+
222
+ let agent_path = fixture.agent_path();
223
+ let agent_cwd_path = fixture.agent_cwd_path();
224
+ let expected_agent_path = PathBuf::from("/completely/different/base/agents");
225
+ let expected_agent_cwd_path = PathBuf::from("/different/current/dir/.forge/agents");
226
+
227
+ // Verify that agent_path uses base_path
228
+ assert_eq!(agent_path, expected_agent_path);
229
+
230
+ // Verify that agent_cwd_path is independent and always relative to CWD
231
+ assert_eq!(agent_cwd_path, expected_agent_cwd_path);
232
+
233
+ // Verify they are different paths
234
+ assert_ne!(agent_path, agent_cwd_path);
235
+ }
236
+
237
+ #[test]
238
+ fn test_global_skills_path() {
239
+ let fixture: Environment = Faker.fake();
240
+ let fixture = fixture.base_path(PathBuf::from("/home/user/.forge"));
241
+
242
+ let actual = fixture.global_skills_path();
243
+ let expected = PathBuf::from("/home/user/.forge/skills");
244
+
245
+ assert_eq!(actual, expected);
246
+ }
247
+
248
+ #[test]
249
+ fn test_agents_skills_path_with_home() {
250
+ let fixture: Environment = Faker.fake();
251
+ let fixture = fixture.home(PathBuf::from("/home/user"));
252
+
253
+ let actual = fixture.agents_skills_path();
254
+ let expected = Some(PathBuf::from("/home/user/.agents/skills"));
255
+
256
+ assert_eq!(actual, expected);
257
+ }
258
+
259
+ #[test]
260
+ fn test_agents_skills_path_without_home() {
261
+ let fixture: Environment = Faker.fake();
262
+ // Explicitly clear the home field
263
+ let mut fixture = fixture;
264
+ fixture.home = None;
265
+
266
+ let actual = fixture.agents_skills_path();
267
+
268
+ assert_eq!(actual, None);
269
+ }
270
+
271
+ #[test]
272
+ fn test_local_skills_path() {
273
+ let fixture: Environment = Faker.fake();
274
+ let fixture = fixture.cwd(PathBuf::from("/projects/my-app"));
275
+
276
+ let actual = fixture.local_skills_path();
277
+ let expected = PathBuf::from("/projects/my-app/.forge/skills");
278
+
279
+ assert_eq!(actual, expected);
280
+ }
281
+
282
+ #[test]
283
+ fn test_skills_paths_independent() {
284
+ let fixture: Environment = Faker.fake();
285
+ let fixture = fixture
286
+ .cwd(PathBuf::from("/projects/my-app"))
287
+ .base_path(PathBuf::from("/home/user/.forge"));
288
+
289
+ let global_path = fixture.global_skills_path();
290
+ let local_path = fixture.local_skills_path();
291
+
292
+ let expected_global = PathBuf::from("/home/user/.forge/skills");
293
+ let expected_local = PathBuf::from("/projects/my-app/.forge/skills");
294
+
295
+ // Verify global path uses base_path
296
+ assert_eq!(global_path, expected_global);
297
+
298
+ // Verify local path uses cwd
299
+ assert_eq!(local_path, expected_local);
300
+
301
+ // Verify they are different paths
302
+ assert_ne!(global_path, local_path);
303
+ }
304
+
305
+ #[test]
306
+ fn test_command_path() {
307
+ let fixture: Environment = Faker.fake();
308
+ let fixture = fixture.base_path(PathBuf::from("/home/user/.forge"));
309
+
310
+ let actual = fixture.command_path();
311
+ let expected = PathBuf::from("/home/user/.forge/commands");
312
+
313
+ assert_eq!(actual, expected);
314
+ }
315
+
316
+ #[test]
317
+ fn test_command_path_local() {
318
+ let fixture: Environment = Faker.fake();
319
+ let fixture = fixture.cwd(PathBuf::from("/projects/my-app"));
320
+
321
+ let actual = fixture.command_path_local();
322
+ let expected = PathBuf::from("/projects/my-app/.forge/commands");
323
+
324
+ assert_eq!(actual, expected);
325
+ }
326
+
327
+ #[test]
328
+ fn test_command_paths_independent() {
329
+ let fixture: Environment = Faker.fake();
330
+ let fixture = fixture
331
+ .cwd(PathBuf::from("/projects/my-app"))
332
+ .base_path(PathBuf::from("/home/user/.forge"));
333
+
334
+ let global_path = fixture.command_path();
335
+ let local_path = fixture.command_path_local();
336
+
337
+ let expected_global = PathBuf::from("/home/user/.forge/commands");
338
+ let expected_local = PathBuf::from("/projects/my-app/.forge/commands");
339
+
340
+ assert_eq!(global_path, expected_global);
341
+ assert_eq!(local_path, expected_local);
342
+ assert_ne!(global_path, local_path);
343
+ }
344
+
345
+ #[test]
346
+ fn test_global_agents_md_path() {
347
+ let fixture: Environment = Faker.fake();
348
+ let fixture = fixture.base_path(PathBuf::from("/home/user/.forge"));
349
+
350
+ let actual = fixture.global_agentsmd_path();
351
+ let expected = PathBuf::from("/home/user/.forge/AGENTS.md");
352
+
353
+ assert_eq!(actual, expected);
354
+ }
355
+
356
+ #[test]
357
+ fn test_local_agents_md_path() {
358
+ let fixture: Environment = Faker.fake();
359
+ let fixture = fixture.cwd(PathBuf::from("/projects/my-app"));
360
+
361
+ let actual = fixture.local_agentsmd_path();
362
+ let expected = PathBuf::from("/projects/my-app/AGENTS.md");
363
+
364
+ assert_eq!(actual, expected);
365
+ }
366
+
367
+ #[test]
368
+ fn test_plans_path() {
369
+ let fixture: Environment = Faker.fake();
370
+ let fixture = fixture.cwd(PathBuf::from("/projects/my-app"));
371
+
372
+ let actual = fixture.plans_path();
373
+ let expected = PathBuf::from("/projects/my-app/plans");
374
+
375
+ assert_eq!(actual, expected);
376
+ }
377
+
378
+ #[test]
379
+ fn test_provider_config_path() {
380
+ let fixture: Environment = Faker.fake();
381
+ let fixture = fixture.base_path(PathBuf::from("/home/user/.forge"));
382
+
383
+ let actual = fixture.provider_config_path();
384
+ let expected = PathBuf::from("/home/user/.forge/provider.json");
385
+
386
+ assert_eq!(actual, expected);
387
+ }
388
+ }
crates/forge_domain/src/error.rs ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::pin::Pin;
2
+
3
+ use derive_more::From;
4
+ use forge_json_repair::JsonRepairError;
5
+ use thiserror::Error;
6
+
7
+ use crate::{AgentId, ConversationId, ProviderId, WorkspaceId};
8
+
9
+ // NOTE: Deriving From for error is a really bad idea. This is because you end
10
+ // up converting errors incorrectly without much context. For eg: You don't want
11
+ // all serde error to be treated as the same. Instead we want to know exactly
12
+ // where that serde failure happened and for what kind of value.
13
+ #[derive(Debug, Error, From)]
14
+ pub enum Error {
15
+ #[error("Missing tool name")]
16
+ ToolCallMissingName,
17
+
18
+ #[error("Missing tool id")]
19
+ ToolCallMissingId,
20
+
21
+ #[error("Unsupported role: {0}")]
22
+ #[from(skip)]
23
+ UnsupportedRole(String),
24
+
25
+ #[error("{0}")]
26
+ EToolCallArgument(ToolCallArgumentError),
27
+
28
+ #[error("JSON deserialization error: {error}")]
29
+ #[from(skip)]
30
+ ToolCallArgument {
31
+ error: JsonRepairError,
32
+ args: String,
33
+ },
34
+
35
+ #[error("JSON deserialization error: {error}")]
36
+ #[from(skip)]
37
+ AgentCallArgument { error: serde_json::error::Error },
38
+
39
+ #[error("Invalid tool call XML: {0}")]
40
+ #[from(skip)]
41
+ ToolCallParse(String),
42
+
43
+ #[error("Invalid conversation id: {0}")]
44
+ ConversationId(uuid::Error),
45
+
46
+ #[error("Agent not found in the arena: {0}")]
47
+ AgentUndefined(AgentId),
48
+
49
+ #[error("Variable not found in output: {0}")]
50
+ #[from(skip)]
51
+ UndefinedVariable(String),
52
+
53
+ #[error("Head agent not found")]
54
+ HeadAgentUndefined,
55
+
56
+ #[error("Agent '{0}' has reached max turns of {1}")]
57
+ MaxTurnsReached(AgentId, u64),
58
+
59
+ #[error("Conversation with ID '{0}' not found")]
60
+ ConversationNotFound(ConversationId),
61
+
62
+ #[error("Missing description for agent: {0}")]
63
+ #[from(skip)]
64
+ MissingAgentDescription(AgentId),
65
+
66
+ #[error("Missing model for agent: {0}")]
67
+ #[from(skip)]
68
+ MissingModel(AgentId),
69
+
70
+ #[error("No model defined for agent: {0}")]
71
+ #[from(skip)]
72
+ NoModelDefined(AgentId),
73
+
74
+ #[error("Empty completion received - no content, tool calls, or valid finish reason")]
75
+ EmptyCompletion,
76
+
77
+ #[error(
78
+ "The model refused to generate a response (safety/content filter). \
79
+ Retrying the same request will produce the same refusal - rephrase \
80
+ the request or switch to a different model."
81
+ )]
82
+ Refusal,
83
+
84
+ #[error(transparent)]
85
+ Retryable(anyhow::Error),
86
+
87
+ #[error("Environment variable {env_var} not found for provider {provider}")]
88
+ EnvironmentVariableNotFound {
89
+ provider: ProviderId,
90
+ env_var: String,
91
+ },
92
+
93
+ #[error("Provider {provider} is not available. Login again to configure it.")]
94
+ ProviderNotAvailable { provider: ProviderId },
95
+
96
+ #[error("Failed to create VertexAI provider: {message}")]
97
+ VertexAiConfiguration { message: String },
98
+
99
+ // Indexing errors
100
+ #[error("No indexing authentication found")]
101
+ AuthTokenNotFound,
102
+
103
+ #[error("Workspace not found")]
104
+ WorkspaceNotFound,
105
+
106
+ #[error("Workspace already initialized with id: {0}")]
107
+ WorkspaceAlreadyInitialized(WorkspaceId),
108
+
109
+ #[error("Failed to sync {count} file(s)")]
110
+ SyncFailed { count: usize },
111
+
112
+ #[error("No default provider and model configured.")]
113
+ NoDefaultSession,
114
+ }
115
+
116
+ pub type Result<A> = std::result::Result<A, Error>;
117
+ pub type BoxStream<A, E> =
118
+ Pin<Box<dyn tokio_stream::Stream<Item = std::result::Result<A, E>> + Send>>;
119
+
120
+ pub type ResultStream<A, E> = std::result::Result<BoxStream<A, E>, E>;
121
+
122
+ #[derive(Debug, derive_more::From)]
123
+ pub struct ToolCallArgumentError(eserde::DeserializationErrors);
124
+
125
+ impl std::error::Error for ToolCallArgumentError {}
126
+
127
+ impl std::fmt::Display for ToolCallArgumentError {
128
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129
+ writeln!(f, "Invalid tool call arguments:")?;
130
+ for error in self.0.iter() {
131
+ writeln!(f, "- {error}")?;
132
+ }
133
+ Ok(())
134
+ }
135
+ }
136
+
137
+ impl Error {
138
+ pub fn into_retryable(self) -> Self {
139
+ use anyhow::anyhow;
140
+ Self::Retryable(anyhow!(self))
141
+ }
142
+
143
+ pub fn env_var_not_found(provider: ProviderId, env_var: &str) -> Self {
144
+ Self::EnvironmentVariableNotFound { provider, env_var: env_var.to_string() }
145
+ }
146
+
147
+ pub fn provider_not_available(provider: ProviderId) -> Self {
148
+ Self::ProviderNotAvailable { provider }
149
+ }
150
+
151
+ pub fn vertex_ai_config(message: impl Into<String>) -> Self {
152
+ Self::VertexAiConfiguration { message: message.into() }
153
+ }
154
+
155
+ pub fn sync_failed(count: usize) -> Self {
156
+ Self::SyncFailed { count }
157
+ }
158
+ }
159
+
160
+ #[cfg(test)]
161
+ mod test {
162
+ use forge_json_repair::JsonRepairError;
163
+ use serde_json::Value;
164
+
165
+ use crate::Error;
166
+
167
+ #[test]
168
+ fn test_debug_serde_error() {
169
+ let args = "{a: 1}";
170
+ let serde_error = serde_json::from_str::<Value>(args).unwrap_err();
171
+ let a = Error::ToolCallArgument {
172
+ error: JsonRepairError::from(serde_error),
173
+ args: args.to_string(),
174
+ };
175
+ let a = anyhow::anyhow!(a);
176
+ eprintln!("{:?}", a.root_cause());
177
+ }
178
+ }
crates/forge_domain/src/event.rs ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::collections::HashMap;
2
+
3
+ use derive_more::{Deref, From};
4
+ use derive_setters::Setters;
5
+ use serde::{Deserialize, Serialize};
6
+ use serde_json::Value;
7
+
8
+ use crate::{Attachment, NamedTool, Template, TerminalContext, ToolName};
9
+
10
+ /// Represents a partial event structure used for CLI event dispatching
11
+ ///
12
+ /// This is an intermediate structure for parsing event JSON from the CLI
13
+ /// before converting it to a full Event type.
14
+ #[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
15
+ pub struct UserCommand {
16
+ pub name: String,
17
+ pub template: Template<Value>,
18
+ pub parameters: Vec<String>,
19
+ }
20
+
21
+ impl UserCommand {
22
+ pub fn new<V: Into<Template<Value>>>(
23
+ name: impl ToString,
24
+ value: V,
25
+ parameters: Vec<String>,
26
+ ) -> Self {
27
+ Self { name: name.to_string(), template: value.into(), parameters }
28
+ }
29
+ }
30
+
31
+ impl From<UserCommand> for Event {
32
+ fn from(value: UserCommand) -> Self {
33
+ Event::new(EventValue::Command(value))
34
+ }
35
+ }
36
+
37
+ impl<T: AsRef<str>> From<T> for EventValue {
38
+ fn from(value: T) -> Self {
39
+ EventValue::Text(UserPrompt(value.as_ref().to_owned()))
40
+ }
41
+ }
42
+
43
+ // We'll use simple strings for JSON schema compatibility
44
+ #[derive(Debug, Deserialize, Serialize, Clone, Setters)]
45
+ #[setters(into, strip_option)]
46
+ pub struct Event {
47
+ pub id: String,
48
+ pub value: Option<EventValue>,
49
+ pub timestamp: String,
50
+ pub attachments: Vec<Attachment>,
51
+
52
+ /// Contains additional context about the prompt that should typically be
53
+ /// included after the `value` as a user message.
54
+ pub additional_context: Option<String>,
55
+ }
56
+
57
+ #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
58
+ pub enum EventValue {
59
+ Text(UserPrompt),
60
+ Command(UserCommand),
61
+ }
62
+
63
+ impl EventValue {
64
+ pub fn as_user_prompt(&self) -> Option<&UserPrompt> {
65
+ match self {
66
+ EventValue::Text(user_prompt) => Some(user_prompt),
67
+ EventValue::Command(_) => None,
68
+ }
69
+ }
70
+
71
+ pub fn as_command(&self) -> Option<&UserCommand> {
72
+ match self {
73
+ EventValue::Text(_user_prompt) => None,
74
+ EventValue::Command(user_command) => Some(user_command),
75
+ }
76
+ }
77
+
78
+ pub fn text(str: impl ToString) -> Self {
79
+ EventValue::Text(UserPrompt(str.to_string()))
80
+ }
81
+ }
82
+
83
+ #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, From, Deref)]
84
+ #[serde(transparent)]
85
+ pub struct UserPrompt(String);
86
+
87
+ #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Setters)]
88
+ pub struct EventContext {
89
+ event: EventContextValue,
90
+ suggestions: Vec<String>,
91
+ variables: HashMap<String, Value>,
92
+ current_date: String,
93
+ /// Structured terminal context injected by [`TerminalContextService`],
94
+ /// or `None` when terminal context is unavailable or disabled.
95
+ #[serde(default, skip_serializing_if = "Option::is_none")]
96
+ terminal_context: Option<TerminalContext>,
97
+ }
98
+
99
+ #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Setters)]
100
+ pub struct EventContextValue {
101
+ pub name: String,
102
+ pub value: String,
103
+ }
104
+
105
+ impl EventContextValue {
106
+ pub fn new<S: Into<String>>(value: S) -> Self {
107
+ Self { name: String::new(), value: value.into() }
108
+ }
109
+ }
110
+
111
+ impl EventContext {
112
+ pub fn new(event: impl Into<EventContextValue>) -> Self {
113
+ Self {
114
+ event: event.into(),
115
+ suggestions: Default::default(),
116
+ variables: Default::default(),
117
+ current_date: chrono::Local::now().format("%Y-%m-%d").to_string(),
118
+ terminal_context: None,
119
+ }
120
+ }
121
+
122
+ /// Converts this EventContext into a feedback event by setting the event
123
+ /// name to "feedback". This should be used when the context already
124
+ /// contains user messages.
125
+ pub fn into_feedback(mut self) -> Self {
126
+ self.event.name = "feedback".to_string();
127
+ self
128
+ }
129
+
130
+ /// Converts this EventContext into a new task event by setting the event
131
+ /// name to "task". This should be used when this is a new task without
132
+ /// prior user messages.
133
+ pub fn into_task(mut self) -> Self {
134
+ self.event.name = "task".to_string();
135
+ self
136
+ }
137
+ }
138
+
139
+ impl NamedTool for Event {
140
+ fn tool_name() -> ToolName {
141
+ ToolName::new("forge_tool_event_dispatch")
142
+ }
143
+ }
144
+
145
+ impl Event {
146
+ pub fn new<V: Into<EventValue>>(value: V) -> Self {
147
+ let id = uuid::Uuid::new_v4().to_string();
148
+ let timestamp = chrono::Utc::now().to_rfc3339();
149
+
150
+ Self {
151
+ id,
152
+ value: Some(value.into()),
153
+ timestamp,
154
+ attachments: Vec::new(),
155
+ additional_context: None,
156
+ }
157
+ }
158
+
159
+ pub fn empty() -> Self {
160
+ let id = uuid::Uuid::new_v4().to_string();
161
+ let timestamp = chrono::Utc::now().to_rfc3339();
162
+
163
+ Self {
164
+ id,
165
+ value: None,
166
+ timestamp,
167
+ attachments: Vec::new(),
168
+ additional_context: None,
169
+ }
170
+ }
171
+ }
172
+
173
+ #[cfg(test)]
174
+ mod tests {
175
+ use super::*;
176
+
177
+ #[test]
178
+ fn test_into_feedback() {
179
+ let event = EventContextValue::new("");
180
+ let context = EventContext::new(event);
181
+
182
+ let feedback_context = context.into_feedback();
183
+
184
+ assert_eq!(feedback_context.event.name, "feedback");
185
+ }
186
+
187
+ #[test]
188
+ fn test_into_task() {
189
+ let event = EventContextValue::new("");
190
+ let context = EventContext::new(event);
191
+
192
+ let task_context = context.into_task();
193
+
194
+ assert_eq!(task_context.event.name, "task");
195
+ }
196
+
197
+ #[test]
198
+ fn test_into_feedback_idempotent() {
199
+ let event = EventContextValue::new("");
200
+ let context = EventContext::new(event);
201
+
202
+ // Call into_feedback twice
203
+ let feedback_context = context.into_feedback().into_feedback();
204
+
205
+ assert_eq!(feedback_context.event.name, "feedback");
206
+ }
207
+
208
+ #[test]
209
+ fn test_into_task_idempotent() {
210
+ let event = EventContextValue::new("");
211
+ let context = EventContext::new(event);
212
+
213
+ // Call into_task twice
214
+ let task_context = context.into_task().into_task();
215
+
216
+ assert_eq!(task_context.event.name, "task");
217
+ }
218
+
219
+ #[test]
220
+ fn test_chaining_methods() {
221
+ let event = EventContextValue::new("initial content");
222
+ let context = EventContext::new(event).into_task();
223
+
224
+ assert_eq!(context.event.name, "task");
225
+ assert_eq!(context.event.value, "initial content");
226
+ }
227
+ }
crates/forge_domain/src/file_operation.rs ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use derive_setters::Setters;
2
+ use serde::{Deserialize, Serialize};
3
+
4
+ use crate::ToolKind;
5
+
6
+ /// Tracks metrics for individual file changes
7
+ #[derive(Debug, Clone, PartialEq, Setters, Serialize, Deserialize)]
8
+ #[setters(into)]
9
+ pub struct FileOperation {
10
+ pub lines_added: u64,
11
+ pub lines_removed: u64,
12
+ /// Content hash of the file. None if file is unreadable (deleted, no
13
+ /// permissions, etc.)
14
+ pub content_hash: Option<String>,
15
+ /// The tool that performed this operation
16
+ pub tool: ToolKind,
17
+ }
18
+
19
+ impl FileOperation {
20
+ /// Creates a new FileChangeMetrics with the specified tool
21
+ /// Other fields default to zero/None and can be set using setters
22
+ pub fn new(tool: ToolKind) -> Self {
23
+ Self { lines_added: 0, lines_removed: 0, content_hash: None, tool }
24
+ }
25
+ }
crates/forge_domain/src/group_by_key.rs ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::collections::HashMap;
2
+ use std::hash::Hash;
3
+
4
+ /// Trait for grouping a collection of items by a key extracted from each item.
5
+ pub trait GroupByKey<K, V> {
6
+ fn group_by_key(value: Vec<V>, key_fn: impl Fn(&V) -> K) -> HashMap<K, Vec<V>>;
7
+ }
8
+
9
+ impl<K: Hash + Eq + Clone, V: Ord + Clone> GroupByKey<K, V> for Vec<V> {
10
+ fn group_by_key(values: Vec<V>, key_fn: impl Fn(&V) -> K) -> HashMap<K, Vec<V>> {
11
+ values
12
+ .iter()
13
+ .fold(
14
+ HashMap::<K, Vec<&V>>::new(),
15
+ |mut acc: HashMap<_, _>, chunk| {
16
+ acc.entry(key_fn(chunk)).or_default().push(chunk);
17
+ acc
18
+ },
19
+ )
20
+ .into_iter()
21
+ .map(|(key, mut chunks)| {
22
+ chunks.sort();
23
+
24
+ (key.clone(), chunks.into_iter().cloned().collect::<Vec<_>>())
25
+ })
26
+ .collect::<HashMap<_, Vec<_>>>()
27
+ }
28
+ }
crates/forge_domain/src/hook.rs ADDED
@@ -0,0 +1,1136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use async_trait::async_trait;
2
+ use derive_more::From;
3
+ use derive_setters::Setters;
4
+
5
+ use crate::{Agent, ChatCompletionMessageFull, Conversation, ModelId, ToolCallFull, ToolResult};
6
+
7
+ /// A container for lifecycle events with agent and model ID context
8
+ ///
9
+ /// This struct provides a consistent structure for all lifecycle events,
10
+ /// containing the agent and model ID along with event-specific payload data.
11
+ #[derive(Debug, PartialEq, Clone)]
12
+ pub struct EventData<P: Send + Sync> {
13
+ /// The agent associated with this event
14
+ pub agent: Agent,
15
+ /// The model ID being used
16
+ pub model_id: ModelId,
17
+ /// Event-specific payload data
18
+ pub payload: P,
19
+ }
20
+
21
+ impl<P: Send + Sync> EventData<P> {
22
+ /// Creates a new event with the given agent, model ID, and payload
23
+ pub fn new(agent: Agent, model_id: ModelId, payload: P) -> Self {
24
+ Self { agent, model_id, payload }
25
+ }
26
+ }
27
+
28
+ /// Payload for the Start event
29
+ #[derive(Debug, PartialEq, Clone, Default)]
30
+ pub struct StartPayload;
31
+
32
+ /// Payload for the End event
33
+ #[derive(Debug, PartialEq, Clone, Default)]
34
+ pub struct EndPayload;
35
+
36
+ /// Payload for the Request event
37
+ #[derive(Debug, PartialEq, Clone, Setters)]
38
+ #[setters(into)]
39
+ pub struct RequestPayload {
40
+ /// The number of requests made
41
+ pub request_count: usize,
42
+ }
43
+
44
+ impl RequestPayload {
45
+ /// Creates a new request payload
46
+ pub fn new(request_count: usize) -> Self {
47
+ Self { request_count }
48
+ }
49
+ }
50
+
51
+ /// Payload for the Response event
52
+ #[derive(Debug, PartialEq, Clone, Setters)]
53
+ #[setters(into)]
54
+ pub struct ResponsePayload {
55
+ /// The full response message from the LLM
56
+ pub message: ChatCompletionMessageFull,
57
+ }
58
+
59
+ impl ResponsePayload {
60
+ /// Creates a new response payload
61
+ pub fn new(message: ChatCompletionMessageFull) -> Self {
62
+ Self { message }
63
+ }
64
+ }
65
+
66
+ /// Payload for the ToolcallStart event
67
+ #[derive(Debug, PartialEq, Clone, Setters)]
68
+ #[setters(into)]
69
+ pub struct ToolcallStartPayload {
70
+ /// The tool call details
71
+ pub tool_call: ToolCallFull,
72
+ }
73
+
74
+ impl ToolcallStartPayload {
75
+ /// Creates a new tool call start payload
76
+ pub fn new(tool_call: ToolCallFull) -> Self {
77
+ Self { tool_call }
78
+ }
79
+ }
80
+
81
+ /// Payload for the ToolcallEnd event
82
+ #[derive(Debug, PartialEq, Clone, Setters)]
83
+ #[setters(into)]
84
+ pub struct ToolcallEndPayload {
85
+ /// The original tool call that was executed
86
+ pub tool_call: ToolCallFull,
87
+ /// The tool result (success or failure)
88
+ pub result: ToolResult,
89
+ }
90
+
91
+ impl ToolcallEndPayload {
92
+ /// Creates a new tool call end payload
93
+ pub fn new(tool_call: ToolCallFull, result: ToolResult) -> Self {
94
+ Self { tool_call, result }
95
+ }
96
+ }
97
+
98
+ /// Lifecycle events that can occur during conversation processing
99
+ #[derive(Debug, PartialEq, Clone, From)]
100
+ pub enum LifecycleEvent {
101
+ /// Event fired when conversation processing starts
102
+ Start(EventData<StartPayload>),
103
+
104
+ /// Event fired when conversation processing ends
105
+ End(EventData<EndPayload>),
106
+
107
+ /// Event fired when a request is made to the LLM
108
+ Request(EventData<RequestPayload>),
109
+
110
+ /// Event fired when a response is received from the LLM
111
+ Response(EventData<ResponsePayload>),
112
+
113
+ /// Event fired when a tool call starts
114
+ ToolcallStart(EventData<ToolcallStartPayload>),
115
+
116
+ /// Event fired when a tool call ends
117
+ ToolcallEnd(EventData<ToolcallEndPayload>),
118
+ }
119
+
120
+ /// Trait for handling lifecycle events
121
+ ///
122
+ /// Implementations of this trait can be used to react to different
123
+ /// stages of conversation processing.
124
+ #[async_trait]
125
+ pub trait EventHandle<T: Send + Sync>: Send + Sync {
126
+ /// Handles a lifecycle event and potentially modifies the conversation
127
+ ///
128
+ /// # Arguments
129
+ /// * `event` - The lifecycle event that occurred
130
+ /// * `conversation` - The current conversation state (mutable)
131
+ ///
132
+ /// # Errors
133
+ /// Returns an error if the event handling fails
134
+ async fn handle(&self, event: &T, conversation: &mut Conversation) -> anyhow::Result<()>;
135
+ }
136
+
137
+ /// Extension trait for combining event handlers
138
+ ///
139
+ /// This trait provides methods to combine multiple event handlers into a single
140
+ /// handler that executes them in sequence.
141
+ pub trait EventHandleExt<T: Send + Sync>: EventHandle<T> {
142
+ /// Combines this handler with another handler, creating a new handler that
143
+ /// runs both in sequence
144
+ ///
145
+ /// When an event is handled, both handlers run in sequence.
146
+ ///
147
+ /// # Arguments
148
+ /// * `other` - Another handler to combine with this one
149
+ ///
150
+ /// # Returns
151
+ /// A new boxed handler that combines both handlers
152
+ fn and<H: EventHandle<T> + 'static>(self, other: H) -> Box<dyn EventHandle<T>>
153
+ where
154
+ Self: Sized + 'static;
155
+ }
156
+
157
+ impl<T: Send + Sync + 'static, A: EventHandle<T> + 'static> EventHandleExt<T> for A {
158
+ fn and<H: EventHandle<T> + 'static>(self, other: H) -> Box<dyn EventHandle<T>>
159
+ where
160
+ Self: Sized + 'static,
161
+ {
162
+ Box::new(CombinedHandler(Box::new(self), Box::new(other)))
163
+ }
164
+ }
165
+
166
+ // Implement EventHandle for Box<dyn EventHandle> to allow using boxed handlers
167
+ #[async_trait]
168
+ impl<T: Send + Sync> EventHandle<T> for Box<dyn EventHandle<T>> {
169
+ async fn handle(&self, event: &T, conversation: &mut Conversation) -> anyhow::Result<()> {
170
+ (**self).handle(event, conversation).await
171
+ }
172
+ }
173
+
174
+ /// A hook that contains handlers for all lifecycle events
175
+ ///
176
+ /// Hooks allow you to attach custom behavior at specific points
177
+ /// during conversation processing.
178
+ pub struct Hook {
179
+ on_start: Box<dyn EventHandle<EventData<StartPayload>>>,
180
+ on_end: Box<dyn EventHandle<EventData<EndPayload>>>,
181
+ on_request: Box<dyn EventHandle<EventData<RequestPayload>>>,
182
+ on_response: Box<dyn EventHandle<EventData<ResponsePayload>>>,
183
+ on_toolcall_start: Box<dyn EventHandle<EventData<ToolcallStartPayload>>>,
184
+ on_toolcall_end: Box<dyn EventHandle<EventData<ToolcallEndPayload>>>,
185
+ }
186
+
187
+ impl Default for Hook {
188
+ fn default() -> Self {
189
+ Self {
190
+ on_start: Box::new(NoOpHandler),
191
+ on_end: Box::new(NoOpHandler),
192
+ on_request: Box::new(NoOpHandler),
193
+ on_response: Box::new(NoOpHandler),
194
+ on_toolcall_start: Box::new(NoOpHandler),
195
+ on_toolcall_end: Box::new(NoOpHandler),
196
+ }
197
+ }
198
+ }
199
+
200
+ impl Hook {
201
+ /// Creates a new hook with custom handlers for all event types
202
+ ///
203
+ /// # Arguments
204
+ /// * `on_start` - Handler for start events
205
+ /// * `on_end` - Handler for end events
206
+ /// * `on_request` - Handler for request events
207
+ /// * `on_response` - Handler for response events
208
+ /// * `on_toolcall_start` - Handler for tool call start events
209
+ /// * `on_toolcall_end` - Handler for tool call end events
210
+ pub fn new(
211
+ on_start: impl Into<Box<dyn EventHandle<EventData<StartPayload>>>>,
212
+ on_end: impl Into<Box<dyn EventHandle<EventData<EndPayload>>>>,
213
+ on_request: impl Into<Box<dyn EventHandle<EventData<RequestPayload>>>>,
214
+ on_response: impl Into<Box<dyn EventHandle<EventData<ResponsePayload>>>>,
215
+ on_toolcall_start: impl Into<Box<dyn EventHandle<EventData<ToolcallStartPayload>>>>,
216
+ on_toolcall_end: impl Into<Box<dyn EventHandle<EventData<ToolcallEndPayload>>>>,
217
+ ) -> Self {
218
+ Self {
219
+ on_start: on_start.into(),
220
+ on_end: on_end.into(),
221
+ on_request: on_request.into(),
222
+ on_response: on_response.into(),
223
+ on_toolcall_start: on_toolcall_start.into(),
224
+ on_toolcall_end: on_toolcall_end.into(),
225
+ }
226
+ }
227
+ }
228
+
229
+ impl Hook {
230
+ /// Sets the start event handler
231
+ ///
232
+ /// # Arguments
233
+ /// * `handler` - Handler for start events (automatically boxed)
234
+ pub fn on_start(
235
+ mut self,
236
+ handler: impl EventHandle<EventData<StartPayload>> + 'static,
237
+ ) -> Self {
238
+ self.on_start = Box::new(handler);
239
+ self
240
+ }
241
+
242
+ /// Sets the end event handler
243
+ ///
244
+ /// # Arguments
245
+ /// * `handler` - Handler for end events (automatically boxed)
246
+ pub fn on_end(mut self, handler: impl EventHandle<EventData<EndPayload>> + 'static) -> Self {
247
+ self.on_end = Box::new(handler);
248
+ self
249
+ }
250
+
251
+ /// Sets the request event handler
252
+ ///
253
+ /// # Arguments
254
+ /// * `handler` - Handler for request events (automatically boxed)
255
+ pub fn on_request(
256
+ mut self,
257
+ handler: impl EventHandle<EventData<RequestPayload>> + 'static,
258
+ ) -> Self {
259
+ self.on_request = Box::new(handler);
260
+ self
261
+ }
262
+
263
+ /// Sets the response event handler
264
+ ///
265
+ /// # Arguments
266
+ /// * `handler` - Handler for response events (automatically boxed)
267
+ pub fn on_response(
268
+ mut self,
269
+ handler: impl EventHandle<EventData<ResponsePayload>> + 'static,
270
+ ) -> Self {
271
+ self.on_response = Box::new(handler);
272
+ self
273
+ }
274
+
275
+ /// Sets the tool call start event handler
276
+ ///
277
+ /// # Arguments
278
+ /// * `handler` - Handler for tool call start events (automatically boxed)
279
+ pub fn on_toolcall_start(
280
+ mut self,
281
+ handler: impl EventHandle<EventData<ToolcallStartPayload>> + 'static,
282
+ ) -> Self {
283
+ self.on_toolcall_start = Box::new(handler);
284
+ self
285
+ }
286
+
287
+ /// Sets the tool call end event handler
288
+ ///
289
+ /// # Arguments
290
+ /// * `handler` - Handler for tool call end events (automatically boxed)
291
+ pub fn on_toolcall_end(
292
+ mut self,
293
+ handler: impl EventHandle<EventData<ToolcallEndPayload>> + 'static,
294
+ ) -> Self {
295
+ self.on_toolcall_end = Box::new(handler);
296
+ self
297
+ }
298
+ }
299
+
300
+ impl Hook {
301
+ /// Combines this hook with another hook, creating a new hook that runs both
302
+ /// handlers in sequence
303
+ ///
304
+ /// When an event is handled, the first hook's handler runs first, then the
305
+ /// second hook's handler runs.
306
+ ///
307
+ /// # Arguments
308
+ /// * `other` - Another hook to combine with this one
309
+ ///
310
+ /// # Returns
311
+ /// A new hook that combines both hooks' handlers
312
+ pub fn zip(self, other: Hook) -> Self {
313
+ Self {
314
+ on_start: self.on_start.and(other.on_start),
315
+ on_end: self.on_end.and(other.on_end),
316
+ on_request: self.on_request.and(other.on_request),
317
+ on_response: self.on_response.and(other.on_response),
318
+ on_toolcall_start: self.on_toolcall_start.and(other.on_toolcall_start),
319
+ on_toolcall_end: self.on_toolcall_end.and(other.on_toolcall_end),
320
+ }
321
+ }
322
+ }
323
+
324
+ // Implement EventHandle for Hook to allow hooks to handle LifecycleEvent
325
+ #[async_trait]
326
+ impl EventHandle<LifecycleEvent> for Hook {
327
+ async fn handle(
328
+ &self,
329
+ event: &LifecycleEvent,
330
+ conversation: &mut Conversation,
331
+ ) -> anyhow::Result<()> {
332
+ match &event {
333
+ LifecycleEvent::Start(data) => self.on_start.handle(data, conversation).await,
334
+ LifecycleEvent::End(data) => self.on_end.handle(data, conversation).await,
335
+ LifecycleEvent::Request(data) => self.on_request.handle(data, conversation).await,
336
+ LifecycleEvent::Response(data) => self.on_response.handle(data, conversation).await,
337
+ LifecycleEvent::ToolcallStart(data) => {
338
+ self.on_toolcall_start.handle(data, conversation).await
339
+ }
340
+ LifecycleEvent::ToolcallEnd(data) => {
341
+ self.on_toolcall_end.handle(data, conversation).await
342
+ }
343
+ }
344
+ }
345
+ }
346
+
347
+ /// A handler that combines two event handlers with sequential execution
348
+ ///
349
+ /// Runs the first handler, then runs the second handler.
350
+ ///
351
+ /// This is used internally by the `Hook::zip` and `EventHandleExt::and`
352
+ /// methods.
353
+ struct CombinedHandler<T: Send + Sync>(Box<dyn EventHandle<T>>, Box<dyn EventHandle<T>>);
354
+
355
+ #[async_trait]
356
+ impl<T: Send + Sync> EventHandle<T> for CombinedHandler<T> {
357
+ async fn handle(&self, event: &T, conversation: &mut Conversation) -> anyhow::Result<()> {
358
+ // Run the first handler
359
+ self.0.handle(event, conversation).await?;
360
+ // Run the second handler with the cloned event
361
+ self.1.handle(event, conversation).await
362
+ }
363
+ }
364
+
365
+ /// A no-op handler that does nothing
366
+ ///
367
+ /// This is useful as a default handler when you only want to
368
+ /// handle specific events.
369
+ #[derive(Debug, Default)]
370
+ pub struct NoOpHandler;
371
+
372
+ #[async_trait]
373
+ impl<T: Send + Sync> EventHandle<T> for NoOpHandler {
374
+ async fn handle(&self, _: &T, _: &mut Conversation) -> anyhow::Result<()> {
375
+ Ok(())
376
+ }
377
+ }
378
+
379
+ #[async_trait]
380
+ impl<T: Send + Sync, F, Fut> EventHandle<T> for F
381
+ where
382
+ F: Fn(&T, &mut Conversation) -> Fut + Send + Sync,
383
+ Fut: std::future::Future<Output = anyhow::Result<()>> + Send,
384
+ {
385
+ async fn handle(&self, event: &T, conversation: &mut Conversation) -> anyhow::Result<()> {
386
+ (self)(event, conversation).await
387
+ }
388
+ }
389
+
390
+ impl<T: Send + Sync, F, Fut> From<F> for Box<dyn EventHandle<T>>
391
+ where
392
+ F: Fn(&T, &mut Conversation) -> Fut + Send + Sync + 'static,
393
+ Fut: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
394
+ {
395
+ fn from(handler: F) -> Self {
396
+ Box::new(handler)
397
+ }
398
+ }
399
+
400
+ #[cfg(test)]
401
+ mod tests {
402
+ use pretty_assertions::assert_eq;
403
+
404
+ use super::*;
405
+ use crate::{Agent, AgentId, Conversation, ModelId, ProviderId};
406
+
407
+ fn test_agent() -> Agent {
408
+ Agent::new(
409
+ AgentId::new("test_agent"),
410
+ ProviderId::FORGE,
411
+ ModelId::new("test-model"),
412
+ )
413
+ }
414
+
415
+ fn test_model_id() -> ModelId {
416
+ ModelId::new("test-model")
417
+ }
418
+
419
+ #[test]
420
+ fn test_no_op_handler() {
421
+ let handler = NoOpHandler;
422
+ let conversation = Conversation::generate();
423
+
424
+ // This test just ensures NoOpHandler compiles and is constructible
425
+ let _ = handler;
426
+ let _ = conversation;
427
+ }
428
+
429
+ #[tokio::test]
430
+ async fn test_hook_on_start() {
431
+ let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
432
+ let events_clone = events.clone();
433
+
434
+ let hook = Hook::default().on_start(
435
+ move |event: &EventData<StartPayload>, _conversation: &mut Conversation| {
436
+ let events = events_clone.clone();
437
+ let event = event.clone();
438
+ async move {
439
+ events.lock().unwrap().push(event);
440
+ Ok(())
441
+ }
442
+ },
443
+ );
444
+
445
+ let mut conversation = Conversation::generate();
446
+
447
+ hook.handle(
448
+ &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)),
449
+ &mut conversation,
450
+ )
451
+ .await
452
+ .unwrap();
453
+
454
+ let handled = events.lock().unwrap();
455
+ assert_eq!(handled.len(), 1);
456
+ assert_eq!(
457
+ handled[0],
458
+ EventData::new(test_agent(), test_model_id(), StartPayload)
459
+ );
460
+ }
461
+
462
+ #[tokio::test]
463
+ async fn test_hook_builder() {
464
+ let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
465
+
466
+ let hook = Hook::default()
467
+ .on_start({
468
+ let events = events.clone();
469
+ move |event: &EventData<StartPayload>, _conversation: &mut Conversation| {
470
+ let events = events.clone();
471
+ let event = LifecycleEvent::Start(event.clone());
472
+ async move {
473
+ events.lock().unwrap().push(event);
474
+ Ok(())
475
+ }
476
+ }
477
+ })
478
+ .on_end({
479
+ let events = events.clone();
480
+ move |event: &EventData<EndPayload>, _conversation: &mut Conversation| {
481
+ let events = events.clone();
482
+ let event = LifecycleEvent::End(event.clone());
483
+ async move {
484
+ events.lock().unwrap().push(event);
485
+ Ok(())
486
+ }
487
+ }
488
+ })
489
+ .on_request({
490
+ let events = events.clone();
491
+ move |event: &EventData<RequestPayload>, _conversation: &mut Conversation| {
492
+ let events = events.clone();
493
+ let event = LifecycleEvent::Request(event.clone());
494
+ async move {
495
+ events.lock().unwrap().push(event);
496
+ Ok(())
497
+ }
498
+ }
499
+ });
500
+
501
+ let mut conversation = Conversation::generate();
502
+
503
+ // Test Start event
504
+ hook.handle(
505
+ &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)),
506
+ &mut conversation,
507
+ )
508
+ .await
509
+ .unwrap();
510
+ // Test End event
511
+ hook.handle(
512
+ &LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)),
513
+ &mut conversation,
514
+ )
515
+ .await
516
+ .unwrap();
517
+ // Test Request event
518
+ hook.handle(
519
+ &LifecycleEvent::Request(EventData::new(
520
+ test_agent(),
521
+ test_model_id(),
522
+ RequestPayload::new(1),
523
+ )),
524
+ &mut conversation,
525
+ )
526
+ .await
527
+ .unwrap();
528
+
529
+ let handled = events.lock().unwrap();
530
+ assert_eq!(handled.len(), 3);
531
+ assert_eq!(
532
+ handled[0],
533
+ LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload))
534
+ );
535
+ assert_eq!(
536
+ handled[1],
537
+ LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload))
538
+ );
539
+ assert_eq!(
540
+ handled[2],
541
+ LifecycleEvent::Request(EventData::new(
542
+ test_agent(),
543
+ test_model_id(),
544
+ RequestPayload::new(1)
545
+ ))
546
+ );
547
+ }
548
+
549
+ #[tokio::test]
550
+ async fn test_hook_all_events() {
551
+ let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
552
+
553
+ let hook = Hook::new(
554
+ {
555
+ let events = events.clone();
556
+ move |event: &EventData<StartPayload>, _conversation: &mut Conversation| {
557
+ let events = events.clone();
558
+ let event = LifecycleEvent::Start(event.clone());
559
+ async move {
560
+ events.lock().unwrap().push(event);
561
+ Ok(())
562
+ }
563
+ }
564
+ },
565
+ {
566
+ let events = events.clone();
567
+ move |event: &EventData<EndPayload>, _conversation: &mut Conversation| {
568
+ let events = events.clone();
569
+ let event = LifecycleEvent::End(event.clone());
570
+ async move {
571
+ events.lock().unwrap().push(event);
572
+ Ok(())
573
+ }
574
+ }
575
+ },
576
+ {
577
+ let events = events.clone();
578
+ move |event: &EventData<RequestPayload>, _conversation: &mut Conversation| {
579
+ let events = events.clone();
580
+ let event = LifecycleEvent::Request(event.clone());
581
+ async move {
582
+ events.lock().unwrap().push(event);
583
+ Ok(())
584
+ }
585
+ }
586
+ },
587
+ {
588
+ let events = events.clone();
589
+ move |event: &EventData<ResponsePayload>, _conversation: &mut Conversation| {
590
+ let events = events.clone();
591
+ let event = LifecycleEvent::Response(event.clone());
592
+ async move {
593
+ events.lock().unwrap().push(event);
594
+ Ok(())
595
+ }
596
+ }
597
+ },
598
+ {
599
+ let events = events.clone();
600
+ move |event: &EventData<ToolcallStartPayload>, _conversation: &mut Conversation| {
601
+ let events = events.clone();
602
+ let event = LifecycleEvent::ToolcallStart(event.clone());
603
+ async move {
604
+ events.lock().unwrap().push(event);
605
+ Ok(())
606
+ }
607
+ }
608
+ },
609
+ {
610
+ let events = events.clone();
611
+ move |event: &EventData<ToolcallEndPayload>, _conversation: &mut Conversation| {
612
+ let events = events.clone();
613
+ let event = LifecycleEvent::ToolcallEnd(event.clone());
614
+ async move {
615
+ events.lock().unwrap().push(event);
616
+ Ok(())
617
+ }
618
+ }
619
+ },
620
+ );
621
+
622
+ let mut conversation = Conversation::generate();
623
+
624
+ let all_events = vec![
625
+ LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)),
626
+ LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)),
627
+ LifecycleEvent::Request(EventData::new(
628
+ test_agent(),
629
+ test_model_id(),
630
+ RequestPayload::new(1),
631
+ )),
632
+ LifecycleEvent::Response(EventData::new(
633
+ test_agent(),
634
+ test_model_id(),
635
+ ResponsePayload::new(ChatCompletionMessageFull {
636
+ content: "test".to_string(),
637
+ reasoning: None,
638
+ tool_calls: vec![],
639
+ thought_signature: None,
640
+ reasoning_details: None,
641
+ usage: crate::Usage::default(),
642
+ finish_reason: None,
643
+ phase: None,
644
+ }),
645
+ )),
646
+ LifecycleEvent::ToolcallStart(EventData::new(
647
+ test_agent(),
648
+ test_model_id(),
649
+ ToolcallStartPayload::new(ToolCallFull::new("test_tool")),
650
+ )),
651
+ LifecycleEvent::ToolcallEnd(EventData::new(
652
+ test_agent(),
653
+ test_model_id(),
654
+ ToolcallEndPayload::new(
655
+ ToolCallFull::new("test_tool"),
656
+ ToolResult::new("test_tool"),
657
+ ),
658
+ )),
659
+ ];
660
+
661
+ for event in all_events {
662
+ hook.handle(&event, &mut conversation).await.unwrap();
663
+ }
664
+
665
+ let handled = events.lock().unwrap();
666
+ assert_eq!(handled.len(), 6);
667
+ }
668
+
669
+ #[tokio::test]
670
+ async fn test_step_mutable_conversation() {
671
+ let title = std::sync::Arc::new(std::sync::Mutex::new(None));
672
+ let hook = Hook::default().on_start({
673
+ let title = title.clone();
674
+ move |_event: &EventData<StartPayload>, _conversation: &mut Conversation| {
675
+ let title = title.clone();
676
+ async move {
677
+ *title.lock().unwrap() = Some("Modified title".to_string());
678
+ Ok(())
679
+ }
680
+ }
681
+ });
682
+ let mut conversation = Conversation::generate();
683
+
684
+ assert!(title.lock().unwrap().is_none());
685
+
686
+ hook.handle(
687
+ &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)),
688
+ &mut conversation,
689
+ )
690
+ .await
691
+ .unwrap();
692
+
693
+ assert_eq!(*title.lock().unwrap(), Some("Modified title".to_string()));
694
+ }
695
+
696
+ #[test]
697
+ fn test_hook_default() {
698
+ let hook = Hook::default();
699
+
700
+ // Just ensure it compiles and is constructible
701
+ let _ = hook;
702
+ }
703
+
704
+ #[tokio::test]
705
+ async fn test_hook_zip() {
706
+ let counter1 = std::sync::Arc::new(std::sync::Mutex::new(0));
707
+ let counter2 = std::sync::Arc::new(std::sync::Mutex::new(0));
708
+
709
+ let hook1 = Hook::default().on_start({
710
+ let counter = counter1.clone();
711
+ move |_event: &EventData<StartPayload>, _conversation: &mut Conversation| {
712
+ let counter = counter.clone();
713
+ async move {
714
+ *counter.lock().unwrap() += 1;
715
+ Ok(())
716
+ }
717
+ }
718
+ });
719
+
720
+ let hook2 = Hook::default().on_start({
721
+ let counter = counter2.clone();
722
+ move |_event: &EventData<StartPayload>, _conversation: &mut Conversation| {
723
+ let counter = counter.clone();
724
+ async move {
725
+ *counter.lock().unwrap() += 1;
726
+ Ok(())
727
+ }
728
+ }
729
+ });
730
+ let combined: Hook = hook1.zip(hook2);
731
+
732
+ let mut conversation = Conversation::generate();
733
+ combined
734
+ .handle(
735
+ &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)),
736
+ &mut conversation,
737
+ )
738
+ .await
739
+ .unwrap();
740
+
741
+ // Both handlers should have been called
742
+ assert_eq!(*counter1.lock().unwrap(), 1);
743
+ assert_eq!(*counter2.lock().unwrap(), 1);
744
+ }
745
+
746
+ #[tokio::test]
747
+ async fn test_hook_zip_multiple() {
748
+ let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
749
+
750
+ let hook1 = Hook::default().on_start({
751
+ let events = events.clone();
752
+ move |event: &EventData<StartPayload>, _conversation: &mut Conversation| {
753
+ let events = events.clone();
754
+ let event = event.clone();
755
+ async move {
756
+ events.lock().unwrap().push(format!("h1:{:?}", event));
757
+ Ok(())
758
+ }
759
+ }
760
+ });
761
+
762
+ let hook2 = Hook::default().on_start({
763
+ let events = events.clone();
764
+ move |event: &EventData<StartPayload>, _conversation: &mut Conversation| {
765
+ let events = events.clone();
766
+ let event = event.clone();
767
+ async move {
768
+ events.lock().unwrap().push(format!("h2:{:?}", event));
769
+ Ok(())
770
+ }
771
+ }
772
+ });
773
+
774
+ let hook3 = Hook::default().on_start({
775
+ let events = events.clone();
776
+ move |event: &EventData<StartPayload>, _conversation: &mut Conversation| {
777
+ let events = events.clone();
778
+ let event = event.clone();
779
+ async move {
780
+ events.lock().unwrap().push(format!("h3:{:?}", event));
781
+ Ok(())
782
+ }
783
+ }
784
+ });
785
+ let combined: Hook = hook1.zip(hook2).zip(hook3);
786
+
787
+ let mut conversation = Conversation::generate();
788
+ combined
789
+ .handle(
790
+ &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)),
791
+ &mut conversation,
792
+ )
793
+ .await
794
+ .unwrap();
795
+
796
+ let handled = events.lock().unwrap();
797
+ assert_eq!(handled.len(), 3);
798
+ assert!(handled[0].starts_with("h1:EventData"));
799
+ assert!(handled[1].starts_with("h2:EventData"));
800
+ assert!(handled[2].starts_with("h3:EventData"));
801
+ }
802
+
803
+ #[tokio::test]
804
+ async fn test_hook_zip_different_events() {
805
+ let start_title = std::sync::Arc::new(std::sync::Mutex::new(None));
806
+ let end_title = std::sync::Arc::new(std::sync::Mutex::new(None));
807
+
808
+ let hook1 = Hook::default()
809
+ .on_start({
810
+ let start_title = start_title.clone();
811
+ move |_event: &EventData<StartPayload>, _conversation: &mut Conversation| {
812
+ let start_title = start_title.clone();
813
+ async move {
814
+ *start_title.lock().unwrap() = Some("Start".to_string());
815
+ Ok(())
816
+ }
817
+ }
818
+ })
819
+ .on_end({
820
+ let end_title = end_title.clone();
821
+ move |_event: &EventData<EndPayload>, _conversation: &mut Conversation| {
822
+ let end_title = end_title.clone();
823
+ async move {
824
+ *end_title.lock().unwrap() = Some("End".to_string());
825
+ Ok(())
826
+ }
827
+ }
828
+ });
829
+ let hook2 = Hook::default();
830
+
831
+ let combined: Hook = hook1.zip(hook2);
832
+
833
+ let mut conversation = Conversation::generate();
834
+
835
+ // Test Start event
836
+ combined
837
+ .handle(
838
+ &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)),
839
+ &mut conversation,
840
+ )
841
+ .await
842
+ .unwrap();
843
+ assert_eq!(*start_title.lock().unwrap(), Some("Start".to_string()));
844
+
845
+ // Test End event
846
+ combined
847
+ .handle(
848
+ &LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)),
849
+ &mut conversation,
850
+ )
851
+ .await
852
+ .unwrap();
853
+ assert_eq!(*end_title.lock().unwrap(), Some("End".to_string()));
854
+ }
855
+
856
+ #[tokio::test]
857
+ async fn test_event_handle_ext_and() {
858
+ let counter1 = std::sync::Arc::new(std::sync::Mutex::new(0));
859
+ let counter2 = std::sync::Arc::new(std::sync::Mutex::new(0));
860
+
861
+ let handler1 = {
862
+ let counter = counter1.clone();
863
+ move |_event: &EventData<StartPayload>, _conversation: &mut Conversation| {
864
+ let counter = counter.clone();
865
+ async move {
866
+ *counter.lock().unwrap() += 1;
867
+ Ok(())
868
+ }
869
+ }
870
+ };
871
+
872
+ let handler2 = {
873
+ let counter = counter2.clone();
874
+ move |_event: &EventData<StartPayload>, _conversation: &mut Conversation| {
875
+ let counter = counter.clone();
876
+ async move {
877
+ *counter.lock().unwrap() += 1;
878
+ Ok(())
879
+ }
880
+ }
881
+ };
882
+
883
+ let combined: Box<dyn EventHandle<EventData<StartPayload>>> = handler1.and(handler2);
884
+
885
+ let mut conversation = Conversation::generate();
886
+ combined
887
+ .handle(
888
+ &EventData::new(test_agent(), test_model_id(), StartPayload),
889
+ &mut conversation,
890
+ )
891
+ .await
892
+ .unwrap();
893
+
894
+ // Both handlers should have been called
895
+ assert_eq!(*counter1.lock().unwrap(), 1);
896
+ assert_eq!(*counter2.lock().unwrap(), 1);
897
+ }
898
+
899
+ #[tokio::test]
900
+ async fn test_event_handle_ext_and_boxed() {
901
+ let counter1 = std::sync::Arc::new(std::sync::Mutex::new(0));
902
+ let counter2 = std::sync::Arc::new(std::sync::Mutex::new(0));
903
+
904
+ let handler1 = {
905
+ let counter = counter1.clone();
906
+ move |_event: &EventData<StartPayload>, _conversation: &mut Conversation| {
907
+ let counter = counter.clone();
908
+ async move {
909
+ *counter.lock().unwrap() += 1;
910
+ Ok(())
911
+ }
912
+ }
913
+ };
914
+
915
+ let handler2 = {
916
+ let counter = counter2.clone();
917
+ move |_event: &EventData<StartPayload>, _conversation: &mut Conversation| {
918
+ let counter = counter.clone();
919
+ async move {
920
+ *counter.lock().unwrap() += 1;
921
+ Ok(())
922
+ }
923
+ }
924
+ };
925
+
926
+ let combined: Box<dyn EventHandle<EventData<StartPayload>>> = handler1.and(handler2);
927
+
928
+ let mut conversation = Conversation::generate();
929
+ combined
930
+ .handle(
931
+ &EventData::new(test_agent(), test_model_id(), StartPayload),
932
+ &mut conversation,
933
+ )
934
+ .await
935
+ .unwrap();
936
+
937
+ // Both handlers should have been called
938
+ assert_eq!(*counter1.lock().unwrap(), 1);
939
+ assert_eq!(*counter2.lock().unwrap(), 1);
940
+ }
941
+
942
+ #[tokio::test]
943
+ async fn test_event_handle_ext_chain() {
944
+ let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
945
+
946
+ let handler1 = {
947
+ let events = events.clone();
948
+ move |event: &EventData<StartPayload>, _conversation: &mut Conversation| {
949
+ let events = events.clone();
950
+ let event = event.clone();
951
+ async move {
952
+ events.lock().unwrap().push(format!("h1:{:?}", event));
953
+ Ok(())
954
+ }
955
+ }
956
+ };
957
+
958
+ let handler2 = {
959
+ let events = events.clone();
960
+ move |event: &EventData<StartPayload>, _conversation: &mut Conversation| {
961
+ let events = events.clone();
962
+ let event = event.clone();
963
+ async move {
964
+ events.lock().unwrap().push(format!("h2:{:?}", event));
965
+ Ok(())
966
+ }
967
+ }
968
+ };
969
+
970
+ let handler3 = {
971
+ let events = events.clone();
972
+ move |event: &EventData<StartPayload>, _conversation: &mut Conversation| {
973
+ let events = events.clone();
974
+ let event = event.clone();
975
+ async move {
976
+ events.lock().unwrap().push(format!("h3:{:?}", event));
977
+ Ok(())
978
+ }
979
+ }
980
+ };
981
+
982
+ // Chain handlers using and()
983
+ let combined: Box<dyn EventHandle<EventData<StartPayload>>> =
984
+ handler1.and(handler2).and(handler3);
985
+
986
+ let mut conversation = Conversation::generate();
987
+ combined
988
+ .handle(
989
+ &EventData::new(test_agent(), test_model_id(), StartPayload),
990
+ &mut conversation,
991
+ )
992
+ .await
993
+ .unwrap();
994
+
995
+ let handled = events.lock().unwrap();
996
+ assert_eq!(handled.len(), 3);
997
+ assert!(handled[0].starts_with("h1:EventData"));
998
+ assert!(handled[1].starts_with("h2:EventData"));
999
+ assert!(handled[2].starts_with("h3:EventData"));
1000
+ }
1001
+
1002
+ #[tokio::test]
1003
+ async fn test_event_handle_ext_with_hook() {
1004
+ let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1005
+ let start_title = std::sync::Arc::new(std::sync::Mutex::new(None));
1006
+
1007
+ let start_handler = {
1008
+ let start_title = start_title.clone();
1009
+ move |_event: &EventData<StartPayload>, _conversation: &mut Conversation| {
1010
+ let start_title = start_title.clone();
1011
+ async move {
1012
+ *start_title.lock().unwrap() = Some("Started".to_string());
1013
+ Ok(())
1014
+ }
1015
+ }
1016
+ };
1017
+
1018
+ let logging_handler = {
1019
+ let events = events.clone();
1020
+ move |event: &EventData<StartPayload>, _conversation: &mut Conversation| {
1021
+ let events = events.clone();
1022
+ let event = event.clone();
1023
+ async move {
1024
+ events.lock().unwrap().push(format!("Event: {:?}", event));
1025
+ Ok(())
1026
+ }
1027
+ }
1028
+ };
1029
+
1030
+ // Combine handlers using extension trait
1031
+ let combined_handler: Box<dyn EventHandle<EventData<StartPayload>>> =
1032
+ start_handler.and(logging_handler);
1033
+
1034
+ let hook = Hook::default().on_start(combined_handler);
1035
+
1036
+ let mut conversation = Conversation::generate();
1037
+ hook.handle(
1038
+ &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)),
1039
+ &mut conversation,
1040
+ )
1041
+ .await
1042
+ .unwrap();
1043
+
1044
+ assert_eq!(events.lock().unwrap().len(), 1);
1045
+ assert!(events.lock().unwrap()[0].starts_with("Event: EventData"));
1046
+ }
1047
+
1048
+ #[tokio::test]
1049
+ async fn test_hook_as_event_handle() {
1050
+ let start_title = std::sync::Arc::new(std::sync::Mutex::new(None));
1051
+ let end_title = std::sync::Arc::new(std::sync::Mutex::new(None));
1052
+
1053
+ let hook = Hook::default()
1054
+ .on_start({
1055
+ let start_title = start_title.clone();
1056
+ move |_event: &EventData<StartPayload>, _conversation: &mut Conversation| {
1057
+ let start_title = start_title.clone();
1058
+ async move {
1059
+ *start_title.lock().unwrap() = Some("Started".to_string());
1060
+ Ok(())
1061
+ }
1062
+ }
1063
+ })
1064
+ .on_end({
1065
+ let end_title = end_title.clone();
1066
+ move |_event: &EventData<EndPayload>, _conversation: &mut Conversation| {
1067
+ let end_title = end_title.clone();
1068
+ async move {
1069
+ *end_title.lock().unwrap() = Some("Ended".to_string());
1070
+ Ok(())
1071
+ }
1072
+ }
1073
+ });
1074
+
1075
+ // Test using handle() directly (EventHandle trait)
1076
+ let mut conversation = Conversation::generate();
1077
+ hook.handle(
1078
+ &LifecycleEvent::Start(EventData::new(test_agent(), test_model_id(), StartPayload)),
1079
+ &mut conversation,
1080
+ )
1081
+ .await
1082
+ .unwrap();
1083
+ assert_eq!(*start_title.lock().unwrap(), Some("Started".to_string()));
1084
+
1085
+ hook.handle(
1086
+ &LifecycleEvent::End(EventData::new(test_agent(), test_model_id(), EndPayload)),
1087
+ &mut conversation,
1088
+ )
1089
+ .await
1090
+ .unwrap();
1091
+ assert_eq!(*end_title.lock().unwrap(), Some("Ended".to_string()));
1092
+ }
1093
+
1094
+ #[tokio::test]
1095
+ async fn test_hook_combination_with_and() {
1096
+ let hook1_title = std::sync::Arc::new(std::sync::Mutex::new(None));
1097
+ let hook2_title = std::sync::Arc::new(std::sync::Mutex::new(None));
1098
+
1099
+ let handler1 = {
1100
+ let hook1_title = hook1_title.clone();
1101
+ move |_event: &EventData<StartPayload>, _conversation: &mut Conversation| {
1102
+ let hook1_title = hook1_title.clone();
1103
+ async move {
1104
+ *hook1_title.lock().unwrap() = Some("Started".to_string());
1105
+ Ok(())
1106
+ }
1107
+ }
1108
+ };
1109
+ let handler2 = {
1110
+ let hook2_title = hook2_title.clone();
1111
+ move |_event: &EventData<StartPayload>, _conversation: &mut Conversation| {
1112
+ let hook2_title = hook2_title.clone();
1113
+ async move {
1114
+ *hook2_title.lock().unwrap() = Some("Ended".to_string());
1115
+ Ok(())
1116
+ }
1117
+ }
1118
+ };
1119
+
1120
+ // Combine handlers using and() extension method
1121
+ let combined: Box<dyn EventHandle<EventData<StartPayload>>> = handler1.and(handler2);
1122
+
1123
+ let mut conversation = Conversation::generate();
1124
+ combined
1125
+ .handle(
1126
+ &EventData::new(test_agent(), test_model_id(), StartPayload),
1127
+ &mut conversation,
1128
+ )
1129
+ .await
1130
+ .unwrap();
1131
+
1132
+ // Both handlers should have been called
1133
+ assert_eq!(*hook1_title.lock().unwrap(), Some("Started".to_string()));
1134
+ assert_eq!(*hook2_title.lock().unwrap(), Some("Ended".to_string()));
1135
+ }
1136
+ }
crates/forge_domain/src/http_config.rs ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+ use strum_macros::EnumString;
3
+
4
+ /// TLS version enum for configuring minimum and maximum TLS protocol versions.
5
+ ///
6
+ /// Used with `HttpConfig` to specify TLS version constraints for HTTP
7
+ /// connections.
8
+ ///
9
+ /// # Example
10
+ /// ```
11
+ /// use forge_domain::{HttpConfig, TlsVersion, TlsBackend};
12
+ ///
13
+ /// let config = HttpConfig {
14
+ /// min_tls_version: Some(TlsVersion::V1_2),
15
+ /// max_tls_version: Some(TlsVersion::V1_3),
16
+ /// tls_backend: TlsBackend::Rustls,
17
+ /// ..HttpConfig::default()
18
+ /// };
19
+ /// ```
20
+ ///
21
+ /// # Environment Variables
22
+ /// - `FORGE_HTTP_MIN_TLS_VERSION`: Set minimum TLS version (e.g., "1.2")
23
+ /// - `FORGE_HTTP_MAX_TLS_VERSION`: Set maximum TLS version (e.g., "1.3")
24
+ #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)]
25
+ #[serde(rename_all = "camelCase")]
26
+ pub enum TlsVersion {
27
+ #[serde(rename = "1.0")]
28
+ V1_0,
29
+ #[serde(rename = "1.1")]
30
+ V1_1,
31
+ #[serde(rename = "1.2")]
32
+ V1_2,
33
+ #[default]
34
+ #[serde(rename = "1.3")]
35
+ V1_3,
36
+ }
37
+
38
+ impl std::fmt::Display for TlsVersion {
39
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40
+ match self {
41
+ TlsVersion::V1_0 => write!(f, "1.0"),
42
+ TlsVersion::V1_1 => write!(f, "1.1"),
43
+ TlsVersion::V1_2 => write!(f, "1.2"),
44
+ TlsVersion::V1_3 => write!(f, "1.3"),
45
+ }
46
+ }
47
+ }
48
+
49
+ impl std::str::FromStr for TlsVersion {
50
+ type Err = String;
51
+
52
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
53
+ match s {
54
+ "1.0" => Ok(TlsVersion::V1_0),
55
+ "1.1" => Ok(TlsVersion::V1_1),
56
+ "1.2" => Ok(TlsVersion::V1_2),
57
+ "1.3" => Ok(TlsVersion::V1_3),
58
+ _ => Err(format!(
59
+ "Invalid TLS version: {s}. Valid options are: 1.0, 1.1, 1.2, 1.3"
60
+ )),
61
+ }
62
+ }
63
+ }
64
+
65
+ #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, EnumString, fake::Dummy)]
66
+ #[serde(rename_all = "camelCase")]
67
+ #[strum(serialize_all = "lowercase")]
68
+ pub enum TlsBackend {
69
+ #[default]
70
+ Default,
71
+ Rustls,
72
+ }
73
+
74
+ impl std::fmt::Display for TlsBackend {
75
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76
+ match self {
77
+ TlsBackend::Default => write!(f, "default"),
78
+ TlsBackend::Rustls => write!(f, "rustls"),
79
+ }
80
+ }
81
+ }
82
+
83
+ /// HTTP client configuration with support for timeouts, connection pooling,
84
+ /// redirects, DNS resolution, TLS settings, and HTTP/2 configuration.
85
+ ///
86
+ /// # TLS Configuration
87
+ /// The `min_tls_version` and `max_tls_version` fields allow you to specify
88
+ /// TLS protocol version constraints. These are optional and when `None`,
89
+ /// the TLS library defaults will be used.
90
+ ///
91
+ /// # HTTP/2 Configuration
92
+ /// The HTTP/2 settings control adaptive window sizing, keep-alive behavior,
93
+ /// and connection management for HTTP/2 connections.
94
+ ///
95
+ /// # Environment Variables
96
+ /// All HttpConfig fields can be configured via environment variables:
97
+ /// - `FORGE_HTTP_CONNECT_TIMEOUT`: Connection timeout in seconds (default: 30)
98
+ /// - `FORGE_HTTP_READ_TIMEOUT`: Read timeout in seconds (default: 900)
99
+ /// - `FORGE_HTTP_POOL_IDLE_TIMEOUT`: Pool idle timeout in seconds (default: 90)
100
+ /// - `FORGE_HTTP_POOL_MAX_IDLE_PER_HOST`: Max idle connections per host
101
+ /// (default: 5)
102
+ /// - `FORGE_HTTP_MAX_REDIRECTS`: Maximum redirects to follow (default: 10)
103
+ /// - `FORGE_HTTP_USE_HICKORY`: Use Hickory DNS resolver (default: false)
104
+ /// - `FORGE_HTTP_TLS_BACKEND`: TLS backend ("default" or "rustls", default:
105
+ /// "default")
106
+ /// - `FORGE_HTTP_MIN_TLS_VERSION`: Minimum TLS version ("1.0", "1.1", "1.2",
107
+ /// "1.3")
108
+ /// - `FORGE_HTTP_MAX_TLS_VERSION`: Maximum TLS version ("1.0", "1.1", "1.2",
109
+ /// "1.3")
110
+ /// - `FORGE_HTTP_ADAPTIVE_WINDOW`: Enable HTTP/2 adaptive window (default:
111
+ /// true)
112
+ /// - `FORGE_HTTP_KEEP_ALIVE_INTERVAL`: Keep-alive interval in seconds (default:
113
+ /// 60, use "none"/"disabled" to disable)
114
+ /// - `FORGE_HTTP_KEEP_ALIVE_TIMEOUT`: Keep-alive timeout in seconds (default:
115
+ /// 10)
116
+ /// - `FORGE_HTTP_KEEP_ALIVE_WHILE_IDLE`: Keep-alive while idle (default: true)
117
+ /// - `FORGE_HTTP_ACCEPT_INVALID_CERTS`: Accept invalid certificates (default:
118
+ /// false) - USE WITH CAUTION
119
+ /// - `FORGE_HTTP_ROOT_CERT_PATHS`: Paths to root certificate files (PEM, CRT,
120
+ /// CER format), multiple paths separated by commas
121
+ ///
122
+ /// # Example
123
+ /// ```
124
+ /// use forge_domain::{HttpConfig, TlsVersion, TlsBackend};
125
+ ///
126
+ /// let config = HttpConfig {
127
+ /// connect_timeout: 30,
128
+ /// min_tls_version: Some(TlsVersion::V1_2),
129
+ /// max_tls_version: Some(TlsVersion::V1_3),
130
+ /// tls_backend: TlsBackend::Rustls,
131
+ /// adaptive_window: true,
132
+ /// keep_alive_interval: Some(60),
133
+ /// ..HttpConfig::default()
134
+ /// };
135
+ /// ```
136
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)]
137
+ #[serde(rename_all = "camelCase")]
138
+ pub struct HttpConfig {
139
+ pub connect_timeout: u64,
140
+ pub read_timeout: u64,
141
+ pub pool_idle_timeout: u64,
142
+ pub pool_max_idle_per_host: usize,
143
+ pub max_redirects: usize,
144
+ pub hickory: bool,
145
+ pub tls_backend: TlsBackend,
146
+ /// Minimum TLS protocol version to use. When `None`, uses TLS library
147
+ /// default.
148
+ pub min_tls_version: Option<TlsVersion>,
149
+ /// Maximum TLS protocol version to use. When `None`, uses TLS library
150
+ /// default.
151
+ pub max_tls_version: Option<TlsVersion>,
152
+ /// Adaptive window sizing for improved flow control.
153
+ pub adaptive_window: bool,
154
+ /// Keep-alive interval in seconds. When `None`, keep-alive is
155
+ /// disabled.
156
+ pub keep_alive_interval: Option<u64>,
157
+ /// Keep-alive timeout in seconds.
158
+ pub keep_alive_timeout: u64,
159
+ /// Keep-alive while connection is idle.
160
+ pub keep_alive_while_idle: bool,
161
+ /// Accept invalid certificates. This should be used with caution.
162
+ pub accept_invalid_certs: bool,
163
+ /// Paths to root certificate files (PEM, CRT, CER format). Multiple paths
164
+ /// can be separated by commas.
165
+ pub root_cert_paths: Option<Vec<String>>,
166
+ }
167
+
168
+ impl Default for HttpConfig {
169
+ fn default() -> Self {
170
+ Self {
171
+ connect_timeout: 30, // 30 seconds
172
+ read_timeout: 900, /* 15 minutes; this should be in sync with the server function
173
+ * execution timeout */
174
+ pool_idle_timeout: 90,
175
+ pool_max_idle_per_host: 5,
176
+ max_redirects: 10,
177
+ hickory: false,
178
+ tls_backend: TlsBackend::default(),
179
+ min_tls_version: None, // Use TLS library default
180
+ max_tls_version: None, // Use TLS library default
181
+ // HTTP/2 defaults - enable HTTP/2 with sensible keep-alive settings
182
+ adaptive_window: true,
183
+ keep_alive_interval: Some(60), // 60 seconds
184
+ keep_alive_timeout: 10, // 10 seconds
185
+ keep_alive_while_idle: true,
186
+ accept_invalid_certs: false, // Default to false for security
187
+ root_cert_paths: None,
188
+ }
189
+ }
190
+ }
191
+
192
+ #[cfg(test)]
193
+ mod tests {
194
+ use std::str::FromStr;
195
+
196
+ use super::*;
197
+
198
+ #[test]
199
+ fn test_tls_version_from_str() {
200
+ assert_eq!(TlsVersion::from_str("1.0").unwrap(), TlsVersion::V1_0);
201
+ assert_eq!(TlsVersion::from_str("1.1").unwrap(), TlsVersion::V1_1);
202
+ assert_eq!(TlsVersion::from_str("1.2").unwrap(), TlsVersion::V1_2);
203
+ assert_eq!(TlsVersion::from_str("1.3").unwrap(), TlsVersion::V1_3);
204
+
205
+ assert!(TlsVersion::from_str("invalid").is_err());
206
+ assert!(TlsVersion::from_str("2.0").is_err());
207
+ }
208
+
209
+ #[test]
210
+ fn test_tls_version_display() {
211
+ assert_eq!(TlsVersion::V1_0.to_string(), "1.0");
212
+ assert_eq!(TlsVersion::V1_1.to_string(), "1.1");
213
+ assert_eq!(TlsVersion::V1_2.to_string(), "1.2");
214
+ assert_eq!(TlsVersion::V1_3.to_string(), "1.3");
215
+ }
216
+
217
+ #[test]
218
+ fn test_tls_version_default() {
219
+ assert_eq!(TlsVersion::default(), TlsVersion::V1_3);
220
+ }
221
+
222
+ #[test]
223
+ fn test_http_config_with_tls_versions() {
224
+ let config = HttpConfig {
225
+ min_tls_version: Some(TlsVersion::V1_2),
226
+ max_tls_version: Some(TlsVersion::V1_3),
227
+ ..HttpConfig::default()
228
+ };
229
+
230
+ assert_eq!(config.min_tls_version, Some(TlsVersion::V1_2));
231
+ assert_eq!(config.max_tls_version, Some(TlsVersion::V1_3));
232
+ }
233
+
234
+ #[test]
235
+ fn test_http_config_http2_defaults() {
236
+ let config = HttpConfig::default();
237
+
238
+ assert!(config.adaptive_window);
239
+ assert_eq!(config.keep_alive_interval, Some(60));
240
+ assert_eq!(config.keep_alive_timeout, 10);
241
+ assert!(config.keep_alive_while_idle);
242
+ }
243
+
244
+ #[test]
245
+ fn test_http_config_http2_custom_values() {
246
+ let config = HttpConfig {
247
+ adaptive_window: false,
248
+ keep_alive_interval: None,
249
+ keep_alive_timeout: 30,
250
+ keep_alive_while_idle: false,
251
+ ..HttpConfig::default()
252
+ };
253
+
254
+ assert!(!config.adaptive_window);
255
+ assert_eq!(config.keep_alive_interval, None);
256
+ assert_eq!(config.keep_alive_timeout, 30);
257
+ assert!(!config.keep_alive_while_idle);
258
+ }
259
+
260
+ #[test]
261
+ fn test_http_config_accept_invalid_certs_defaults() {
262
+ let config = HttpConfig::default();
263
+ assert!(!config.accept_invalid_certs);
264
+ }
265
+
266
+ #[test]
267
+ fn test_http_config_accept_invalid_certs_custom() {
268
+ let config = HttpConfig { accept_invalid_certs: true, ..HttpConfig::default() };
269
+ assert!(config.accept_invalid_certs);
270
+ }
271
+
272
+ #[test]
273
+ fn test_http_config_root_cert_paths_custom() {
274
+ let cert_paths = vec![
275
+ "/path/to/cert1.pem".to_string(),
276
+ "/path/to/cert2.crt".to_string(),
277
+ ];
278
+ let config = HttpConfig {
279
+ root_cert_paths: Some(cert_paths.clone()),
280
+ ..HttpConfig::default()
281
+ };
282
+ assert_eq!(config.root_cert_paths, Some(cert_paths));
283
+ }
284
+ }
crates/forge_domain/src/image.rs ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use base64::Engine;
2
+ use derive_getters::Getters;
3
+ use serde::{Deserialize, Serialize};
4
+
5
+ #[derive(Default, Clone, Debug, Serialize, Deserialize, Getters, PartialEq, Eq, Hash)]
6
+ pub struct Image {
7
+ url: String,
8
+ mime_type: String,
9
+ }
10
+
11
+ impl Image {
12
+ pub fn new_bytes(content: Vec<u8>, mime_type: impl ToString) -> Self {
13
+ let mime_type = mime_type.to_string();
14
+ let base64_encoded = base64::engine::general_purpose::STANDARD.encode(&content);
15
+ Self::new_base64(base64_encoded, mime_type)
16
+ }
17
+
18
+ // Returns the base64 image without the prefix.
19
+ pub fn data(&self) -> &str {
20
+ self.url
21
+ .strip_prefix(&format!("data:{};base64,", self.mime_type))
22
+ .unwrap_or(&self.url)
23
+ }
24
+
25
+ pub fn new_base64(base64_encoded: String, mime_type: impl ToString) -> Self {
26
+ let mime_type = mime_type.to_string();
27
+ let content = format!("data:{mime_type};base64,{base64_encoded}");
28
+ Self { url: content, mime_type }
29
+ }
30
+ }
crates/forge_domain/src/lib.rs ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ mod agent;
2
+ mod attachment;
3
+ mod auth;
4
+ mod chat_request;
5
+ mod chat_response;
6
+ mod command;
7
+ mod compact;
8
+ mod console;
9
+ mod context;
10
+ mod conversation;
11
+ mod conversation_html;
12
+ mod data_gen;
13
+ mod env;
14
+ mod error;
15
+ mod event;
16
+ mod file;
17
+ mod file_operation;
18
+ mod group_by_key;
19
+ mod hook;
20
+ mod http_config;
21
+ mod image;
22
+ mod max_tokens;
23
+ mod mcp;
24
+ mod mcp_servers;
25
+ mod merge;
26
+ mod message;
27
+ mod message_pattern;
28
+ mod migration;
29
+ mod model;
30
+ mod model_config;
31
+ mod node;
32
+ mod point;
33
+ mod policies;
34
+ mod provider;
35
+ mod reasoning;
36
+ mod repo;
37
+ mod result_stream_ext;
38
+ mod session_metrics;
39
+ mod shell;
40
+ mod skill;
41
+ mod snapshot;
42
+ mod suggestion;
43
+ mod system_context;
44
+ mod temperature;
45
+ mod template;
46
+ mod terminal_context;
47
+ mod tools;
48
+
49
+ mod tool_order;
50
+ mod top_k;
51
+ mod top_p;
52
+ mod transformer;
53
+ mod update;
54
+ mod validation;
55
+ mod workspace;
56
+ mod xml;
57
+
58
+ pub use agent::*;
59
+ pub use attachment::*;
60
+ pub use chat_request::*;
61
+ pub use chat_response::*;
62
+ pub use command::*;
63
+ pub use compact::*;
64
+ pub use console::*;
65
+ pub use context::*;
66
+ pub use conversation::*;
67
+ pub use conversation_html::*;
68
+ pub use data_gen::*;
69
+ pub use env::*;
70
+ pub use error::*;
71
+ pub use event::*;
72
+ pub use file::*;
73
+ pub use file_operation::*;
74
+ pub use fuzzy_search::*;
75
+ pub use group_by_key::*;
76
+ pub use hook::*;
77
+ pub use http_config::*;
78
+ pub use image::*;
79
+ pub use max_tokens::*;
80
+ pub use mcp::*;
81
+ pub use mcp_servers::*;
82
+ pub use message::*;
83
+ pub use message_pattern::*;
84
+ pub use migration::*;
85
+ pub use model::*;
86
+ pub use model_config::*;
87
+ pub use node::*;
88
+ pub use point::*;
89
+ pub use policies::*;
90
+ pub use provider::*;
91
+ pub use reasoning::*;
92
+ pub use repo::*;
93
+ pub use result_stream_ext::*;
94
+ pub use session_metrics::*;
95
+ pub use shell::*;
96
+ pub use skill::*;
97
+ pub use snapshot::*;
98
+ pub use suggestion::*;
99
+ pub use system_context::*;
100
+ pub use temperature::*;
101
+ pub use template::*;
102
+ pub use terminal_context::*;
103
+ pub use tool_order::*;
104
+ pub use tools::*;
105
+ pub use top_k::*;
106
+ pub use top_p::*;
107
+ pub use transformer::*;
108
+ pub use update::*;
109
+ pub use validation::*;
110
+ pub use workspace::*;
111
+ pub use xml::*;
112
+ pub mod line_numbers;
113
+ pub use auth::*;
114
+ pub use line_numbers::*;
115
+
116
+ /// Type alias for tokio mpsc Sender that sends ChatResponse results
117
+ pub type ArcSender = tokio::sync::mpsc::Sender<anyhow::Result<ChatResponse>>;
118
+
119
+ mod fuzzy_search;
crates/forge_domain/src/max_tokens.rs ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::fmt;
2
+ use std::ops::Deref;
3
+
4
+ use schemars::JsonSchema;
5
+ use serde::{Deserialize, Deserializer, Serialize, Serializer};
6
+
7
+ /// A newtype for max_tokens values with built-in validation
8
+ ///
9
+ /// Max tokens controls the maximum number of tokens the model can generate:
10
+ /// - Lower values (e.g., 100) limit response length for concise outputs
11
+ /// - Higher values (e.g., 4000) allow for longer, more detailed responses
12
+ /// - Valid range is 1 to 100,000 (reasonable upper bound for most models)
13
+ /// - If not specified, the model provider's default will be used
14
+ #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, JsonSchema)]
15
+ pub struct MaxTokens(u32);
16
+
17
+ impl MaxTokens {
18
+ /// Creates a new MaxTokens value, returning an error if outside the valid
19
+ /// range (1 to 100,000)
20
+ pub fn new(value: u32) -> Result<Self, String> {
21
+ if Self::is_valid(value) {
22
+ Ok(Self(value))
23
+ } else {
24
+ Err(format!(
25
+ "max_tokens must be between 1 and 100000, got {value}"
26
+ ))
27
+ }
28
+ }
29
+
30
+ /// Creates a new MaxTokens value without validation
31
+ ///
32
+ /// # Safety
33
+ /// This function should only be used when the value is known to be valid
34
+ pub fn new_unchecked(value: u32) -> Self {
35
+ debug_assert!(Self::is_valid(value), "invalid max_tokens: {value}");
36
+ Self(value)
37
+ }
38
+
39
+ /// Returns true if the max_tokens value is within the valid range (1 to
40
+ /// 100,000)
41
+ pub fn is_valid(value: u32) -> bool {
42
+ (1..=100_000).contains(&value)
43
+ }
44
+
45
+ /// Returns the inner u32 value
46
+ pub fn value(&self) -> u32 {
47
+ self.0
48
+ }
49
+ }
50
+
51
+ impl Deref for MaxTokens {
52
+ type Target = u32;
53
+
54
+ fn deref(&self) -> &Self::Target {
55
+ &self.0
56
+ }
57
+ }
58
+
59
+ impl From<MaxTokens> for u32 {
60
+ fn from(max_tokens: MaxTokens) -> Self {
61
+ max_tokens.0
62
+ }
63
+ }
64
+
65
+ impl fmt::Display for MaxTokens {
66
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67
+ write!(f, "{}", self.0)
68
+ }
69
+ }
70
+
71
+ impl Serialize for MaxTokens {
72
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
73
+ where
74
+ S: Serializer,
75
+ {
76
+ serializer.serialize_u32(self.0)
77
+ }
78
+ }
79
+
80
+ impl<'de> Deserialize<'de> for MaxTokens {
81
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
82
+ where
83
+ D: Deserializer<'de>,
84
+ {
85
+ use serde::de::Error;
86
+ let value = u32::deserialize(deserializer)?;
87
+ if Self::is_valid(value) {
88
+ Ok(Self(value))
89
+ } else {
90
+ Err(Error::custom(format!(
91
+ "max_tokens must be between 1 and 100000, got {value}"
92
+ )))
93
+ }
94
+ }
95
+ }
96
+
97
+ #[cfg(test)]
98
+ mod tests {
99
+ use pretty_assertions::assert_eq;
100
+ use serde_json::json;
101
+
102
+ use super::*;
103
+
104
+ #[test]
105
+ fn test_max_tokens_creation() {
106
+ // Valid max_tokens values should be created successfully
107
+ let valid_values = [1, 100, 1000, 4000, 8000, 100_000];
108
+ for value in valid_values {
109
+ let result = MaxTokens::new(value);
110
+ assert!(result.is_ok(), "MaxTokens {value} should be valid");
111
+ assert_eq!(result.unwrap().value(), value);
112
+ }
113
+
114
+ // Invalid max_tokens values should return an error
115
+ let invalid_values = [0, 100_001, 200_000, 1_000_000];
116
+ for value in invalid_values {
117
+ let result = MaxTokens::new(value);
118
+ assert!(result.is_err(), "MaxTokens {value} should be invalid");
119
+ assert!(
120
+ result
121
+ .unwrap_err()
122
+ .contains("max_tokens must be between 1 and 100000"),
123
+ "Error should mention valid range"
124
+ );
125
+ }
126
+ }
127
+
128
+ #[test]
129
+ fn test_max_tokens_serialization() {
130
+ let max_tokens = MaxTokens::new(4000).unwrap();
131
+ let json = serde_json::to_value(max_tokens).unwrap();
132
+
133
+ if let serde_json::Value::Number(num) = &json {
134
+ let int_val = num.as_u64().unwrap();
135
+ assert_eq!(int_val, 4000);
136
+ } else {
137
+ panic!("Expected a number, got {json:?}");
138
+ }
139
+ }
140
+
141
+ #[test]
142
+ fn test_max_tokens_deserialization() {
143
+ // Valid max_tokens values should deserialize correctly
144
+ let valid_values = [1, 100, 1000, 4000, 8000, 100_000];
145
+ for value in valid_values {
146
+ let json = json!(value);
147
+ let max_tokens: Result<MaxTokens, _> = serde_json::from_value(json);
148
+ assert!(
149
+ max_tokens.is_ok(),
150
+ "Valid max_tokens {value} should deserialize"
151
+ );
152
+ assert_eq!(max_tokens.unwrap().value(), value);
153
+ }
154
+
155
+ // Invalid max_tokens values should fail deserialization
156
+ let invalid_values = [0, 100_001, 200_000, 1_000_000];
157
+ for value in invalid_values {
158
+ let json = json!(value);
159
+ let max_tokens: Result<MaxTokens, _> = serde_json::from_value(json);
160
+ assert!(
161
+ max_tokens.is_err(),
162
+ "Invalid max_tokens {value} should fail deserialization"
163
+ );
164
+ let err = max_tokens.unwrap_err().to_string();
165
+ assert!(
166
+ err.contains("max_tokens must be between 1 and 100000"),
167
+ "Error should mention valid range: {err}"
168
+ );
169
+ }
170
+ }
171
+
172
+ #[test]
173
+ fn test_max_tokens_in_struct() {
174
+ #[derive(Serialize, Deserialize, Debug)]
175
+ struct TestStruct {
176
+ max_tokens: MaxTokens,
177
+ }
178
+
179
+ // Valid max_tokens
180
+ let json = json!({
181
+ "max_tokens": 4000
182
+ });
183
+ let test_struct: Result<TestStruct, _> = serde_json::from_value(json);
184
+ assert!(test_struct.is_ok());
185
+ assert_eq!(test_struct.unwrap().max_tokens.value(), 4000);
186
+
187
+ // Invalid max_tokens
188
+ let json = json!({
189
+ "max_tokens": 200_000
190
+ });
191
+ let test_struct: Result<TestStruct, _> = serde_json::from_value(json);
192
+ assert!(test_struct.is_err());
193
+ let err = test_struct.unwrap_err().to_string();
194
+ assert!(
195
+ err.contains("max_tokens must be between 1 and 100000"),
196
+ "Error should mention valid range: {err}"
197
+ );
198
+ }
199
+ }
crates/forge_domain/src/mcp_servers.rs ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::collections::{HashMap, hash_map};
2
+
3
+ use serde::{Deserialize, Serialize};
4
+
5
+ use crate::{ServerName, ToolDefinition};
6
+
7
+ /// Cache for MCP tool definitions
8
+ ///
9
+ /// Simplified cache structure that stores only the essential data.
10
+ /// Validation and TTL checking are handled by the infrastructure layer
11
+ /// using cacache's built-in metadata capabilities.
12
+ #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, derive_setters::Setters)]
13
+ #[serde(rename_all = "camelCase")]
14
+ #[setters(strip_option, into)]
15
+ pub struct McpServers {
16
+ /// Successfully loaded MCP servers with their tools
17
+ servers: HashMap<ServerName, Vec<ToolDefinition>>,
18
+ /// Failed MCP servers with their error messages
19
+ #[serde(default)]
20
+ failures: HashMap<ServerName, String>,
21
+ }
22
+
23
+ impl McpServers {
24
+ /// Create a new cache entry with servers and failures
25
+ pub fn new(
26
+ servers: HashMap<ServerName, Vec<ToolDefinition>>,
27
+ failures: HashMap<ServerName, String>,
28
+ ) -> Self {
29
+ Self { servers, failures }
30
+ }
31
+
32
+ /// Get the successful servers
33
+ pub fn get_servers(&self) -> &HashMap<ServerName, Vec<ToolDefinition>> {
34
+ &self.servers
35
+ }
36
+
37
+ /// Get the failed servers
38
+ pub fn get_failures(&self) -> &HashMap<ServerName, String> {
39
+ &self.failures
40
+ }
41
+ }
42
+
43
+ impl IntoIterator for McpServers {
44
+ type Item = (ServerName, Vec<ToolDefinition>);
45
+ type IntoIter = hash_map::IntoIter<ServerName, Vec<ToolDefinition>>;
46
+
47
+ fn into_iter(self) -> Self::IntoIter {
48
+ self.servers.into_iter()
49
+ }
50
+ }
crates/forge_domain/src/merge.rs ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use ::std::collections::HashMap;
2
+ use ::std::hash::Hash;
3
+
4
+ pub mod std {
5
+ pub fn overwrite<T>(base: &mut T, other: T) {
6
+ *base = other;
7
+ }
8
+ }
9
+
10
+ pub mod vec {
11
+
12
+ use std::collections::HashMap;
13
+
14
+ use merge::Merge;
15
+
16
+ use super::Key;
17
+
18
+ #[allow(unused)]
19
+ pub fn unify_by_key<T: Merge + Key>(base: &mut Vec<T>, other: Vec<T>)
20
+ where
21
+ T::Id: Clone + std::hash::Hash + Eq,
22
+ {
23
+ // Create a HashMap for O(1) lookup of base agents by their key
24
+ let mut base_map: HashMap<T::Id, usize> = HashMap::new();
25
+ for (index, agent) in base.iter().enumerate() {
26
+ base_map.insert(agent.key().clone(), index);
27
+ }
28
+
29
+ for other_agent in other {
30
+ if let Some(&index) = base_map.get(other_agent.key()) {
31
+ // If the base contains an agent with the same Key, merge them
32
+ if let Some(base_agent) = base.get_mut(index) {
33
+ base_agent.merge(other_agent);
34
+ }
35
+ } else {
36
+ // Otherwise, append the other agent to the base list
37
+ base.push(other_agent);
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ pub fn option<A>(base: &mut Option<A>, other: Option<A>) {
44
+ if other.is_some() {
45
+ *base = other;
46
+ }
47
+ }
48
+ #[allow(unused)]
49
+ pub trait Key {
50
+ type Id: Eq;
51
+ fn key(&self) -> &Self::Id;
52
+ }
53
+
54
+ #[allow(unused)]
55
+ pub fn hashmap<K: Eq + Hash, V>(base: &mut HashMap<K, V>, other: HashMap<K, V>) {
56
+ for (key, value) in other {
57
+ base.insert(key, value);
58
+ }
59
+ }
crates/forge_domain/src/message.rs ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use derive_more::derive::From;
2
+ use derive_setters::Setters;
3
+ use serde::{Deserialize, Serialize};
4
+ use strum_macros::{EnumString, IntoStaticStr};
5
+
6
+ use super::{ToolCall, ToolCallFull};
7
+ use crate::TokenCount;
8
+ use crate::reasoning::{Reasoning, ReasoningFull};
9
+
10
+ /// Labels an assistant message as intermediate commentary or the final answer.
11
+ ///
12
+ /// For models like `gpt-5.3-codex` and beyond, when sending follow-up requests,
13
+ /// preserve and resend phase on all assistant messages -- dropping it can
14
+ /// degrade performance.
15
+ #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
16
+ #[serde(rename_all = "snake_case")]
17
+ pub enum MessagePhase {
18
+ /// Intermediate commentary produced while the model is reasoning.
19
+ Commentary,
20
+ /// The final answer from the model.
21
+ FinalAnswer,
22
+ }
23
+
24
+ #[derive(Default, Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
25
+ pub struct Usage {
26
+ pub prompt_tokens: TokenCount,
27
+ pub completion_tokens: TokenCount,
28
+ pub total_tokens: TokenCount,
29
+ pub cached_tokens: TokenCount,
30
+ pub cost: Option<f64>,
31
+ }
32
+
33
+ impl Usage {
34
+ /// Accumulates usage from another Usage instance by summing all fields.
35
+ ///
36
+ /// Use this for aggregating usage across **independent** requests (e.g.,
37
+ /// session-level totals where each message has its own final usage).
38
+ pub fn accumulate(mut self, other: &Usage) -> Self {
39
+ self.prompt_tokens = self.prompt_tokens + other.prompt_tokens;
40
+ self.completion_tokens = self.completion_tokens + other.completion_tokens;
41
+ self.total_tokens = self.total_tokens + other.total_tokens;
42
+ self.cached_tokens = self.cached_tokens + other.cached_tokens;
43
+ self.cost = match (self.cost, other.cost) {
44
+ (Some(a), Some(b)) => Some(a + b),
45
+ (Some(a), None) => Some(a),
46
+ (None, Some(b)) => Some(b),
47
+ (None, None) => None,
48
+ };
49
+ self
50
+ }
51
+
52
+ /// Merges usage from another Usage instance using a "last non-zero wins"
53
+ /// strategy.
54
+ ///
55
+ /// Use this when combining **partial** usage events within a single
56
+ /// streaming response where values are **cumulative** (not incremental):
57
+ /// - `message_start`: `input_tokens=1000, output_tokens=1`
58
+ /// - `message_delta`: `input_tokens=0, output_tokens=75` (cumulative
59
+ /// total)
60
+ ///
61
+ /// For each field, the larger of the two values is kept. This prevents
62
+ /// double-counting when providers report cumulative token counts across
63
+ /// multiple events.
64
+ ///
65
+ /// Cost is summed since cost events are always additive.
66
+ pub fn merge(mut self, other: &Usage) -> Self {
67
+ self.prompt_tokens = self.prompt_tokens.max(other.prompt_tokens);
68
+ self.completion_tokens = self.completion_tokens.max(other.completion_tokens);
69
+ self.total_tokens = self.total_tokens.max(other.total_tokens);
70
+ self.cached_tokens = self.cached_tokens.max(other.cached_tokens);
71
+ self.cost = match (self.cost, other.cost) {
72
+ (Some(a), Some(b)) => Some(a + b),
73
+ (Some(a), None) => Some(a),
74
+ (None, Some(b)) => Some(b),
75
+ (None, None) => None,
76
+ };
77
+ self
78
+ }
79
+ }
80
+
81
+ /// Represents a message that was received from the LLM provider
82
+ /// NOTE: Tool call messages are part of the larger Response object and not part
83
+ /// of the message.
84
+ #[derive(Default, Clone, Debug, Setters, PartialEq)]
85
+ #[setters(into, strip_option)]
86
+ pub struct ChatCompletionMessage {
87
+ pub content: Option<Content>,
88
+ pub thought_signature: Option<String>,
89
+ pub reasoning: Option<Content>,
90
+ pub reasoning_details: Option<Vec<Reasoning>>,
91
+ pub tool_calls: Vec<ToolCall>,
92
+ pub finish_reason: Option<FinishReason>,
93
+ pub usage: Option<Usage>,
94
+ /// Phase label for assistant messages (e.g. `Commentary` or `FinalAnswer`).
95
+ /// Preserved from the response and replayed back on subsequent requests.
96
+ pub phase: Option<MessagePhase>,
97
+ }
98
+
99
+ impl From<FinishReason> for ChatCompletionMessage {
100
+ fn from(value: FinishReason) -> Self {
101
+ ChatCompletionMessage::default().finish_reason(value)
102
+ }
103
+ }
104
+
105
+ /// Represents partial or full content of a message
106
+ #[derive(Clone, Debug, PartialEq, Eq, From)]
107
+ pub enum Content {
108
+ Part(ContentPart),
109
+ Full(ContentFull),
110
+ }
111
+
112
+ impl Content {
113
+ pub fn as_str(&self) -> &str {
114
+ match self {
115
+ Content::Part(part) => &part.0,
116
+ Content::Full(full) => &full.0,
117
+ }
118
+ }
119
+
120
+ pub fn part(content: impl ToString) -> Self {
121
+ Content::Part(ContentPart(content.to_string()))
122
+ }
123
+
124
+ pub fn full(content: impl ToString) -> Self {
125
+ Content::Full(ContentFull(content.to_string()))
126
+ }
127
+
128
+ pub fn is_empty(&self) -> bool {
129
+ self.as_str().is_empty()
130
+ }
131
+
132
+ pub fn is_part(&self) -> bool {
133
+ matches!(self, Content::Part(_))
134
+ }
135
+ }
136
+
137
+ /// Used typically when streaming is enabled
138
+ #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
139
+ #[serde(transparent)]
140
+ pub struct ContentPart(String);
141
+
142
+ /// Used typically when full responses are enabled (Streaming is disabled)
143
+ #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
144
+ #[serde(transparent)]
145
+ pub struct ContentFull(String);
146
+
147
+ impl<T: AsRef<str>> From<T> for Content {
148
+ fn from(value: T) -> Self {
149
+ Content::Full(ContentFull(value.as_ref().to_string()))
150
+ }
151
+ }
152
+
153
+ /// The reason why the model stopped generating output.
154
+ /// Read more: https://platform.openai.com/docs/guides/function-calling#edge-cases
155
+ #[derive(Clone, Debug, Deserialize, Serialize, EnumString, IntoStaticStr, PartialEq, Eq)]
156
+ pub enum FinishReason {
157
+ /// The model stopped generating output because it reached the maximum
158
+ /// allowed length.
159
+ #[strum(serialize = "length")]
160
+ Length,
161
+ /// The model stopped generating output because it encountered content that
162
+ /// violated filters.
163
+ #[strum(serialize = "content_filter")]
164
+ ContentFilter,
165
+ /// The model stopped generating output because it made a tool call.
166
+ #[strum(serialize = "tool_calls")]
167
+ ToolCalls,
168
+ /// The model stopped generating output normally.
169
+ #[strum(serialize = "stop", serialize = "end_turn")]
170
+ Stop,
171
+ }
172
+
173
+ impl ChatCompletionMessage {
174
+ pub fn assistant(content: impl Into<Content>) -> ChatCompletionMessage {
175
+ ChatCompletionMessage::default().content(content.into())
176
+ }
177
+
178
+ pub fn add_reasoning_detail(mut self, detail: impl Into<Reasoning>) -> Self {
179
+ let detail = detail.into();
180
+ if let Some(ref mut details) = self.reasoning_details {
181
+ details.push(detail);
182
+ } else {
183
+ self.reasoning_details = Some(vec![detail]);
184
+ }
185
+ self
186
+ }
187
+
188
+ pub fn add_tool_call(mut self, call_tool: impl Into<ToolCall>) -> Self {
189
+ self.tool_calls.push(call_tool.into());
190
+ self
191
+ }
192
+
193
+ pub fn extend_calls(mut self, calls: Vec<impl Into<ToolCall>>) -> Self {
194
+ self.tool_calls.extend(calls.into_iter().map(Into::into));
195
+ self
196
+ }
197
+
198
+ pub fn finish_reason_opt(mut self, reason: Option<FinishReason>) -> Self {
199
+ self.finish_reason = reason;
200
+ self
201
+ }
202
+
203
+ pub fn content_part(mut self, content: impl ToString) -> Self {
204
+ self.content = Some(Content::Part(ContentPart(content.to_string())));
205
+ self
206
+ }
207
+
208
+ pub fn content_full(mut self, content: impl ToString) -> Self {
209
+ self.content = Some(Content::Full(ContentFull(content.to_string())));
210
+ self
211
+ }
212
+ }
213
+
214
+ /// Represents a complete message from the LLM provider with all content
215
+ /// collected This is typically used after processing a stream of
216
+ /// ChatCompletionMessage
217
+ #[derive(Clone, Debug, PartialEq)]
218
+ pub struct ChatCompletionMessageFull {
219
+ pub content: String,
220
+ pub thought_signature: Option<String>,
221
+ pub reasoning: Option<String>,
222
+ pub tool_calls: Vec<ToolCallFull>,
223
+ pub reasoning_details: Option<Vec<ReasoningFull>>,
224
+ pub usage: Usage,
225
+ pub finish_reason: Option<FinishReason>,
226
+ /// Phase label for the assistant message (e.g. `Commentary` or
227
+ /// `FinalAnswer`).
228
+ pub phase: Option<MessagePhase>,
229
+ }
230
+
231
+ #[cfg(test)]
232
+ mod tests {
233
+ use std::str::FromStr;
234
+
235
+ use pretty_assertions::assert_eq;
236
+
237
+ use super::*;
238
+ #[test]
239
+ fn test_usage_accumulate_with_both_costs() {
240
+ let fixture_usage_1 = Usage {
241
+ prompt_tokens: TokenCount::Actual(100),
242
+ completion_tokens: TokenCount::Actual(50),
243
+ total_tokens: TokenCount::Actual(150),
244
+ cached_tokens: TokenCount::Actual(20),
245
+ cost: Some(0.01),
246
+ };
247
+
248
+ let fixture_usage_2 = Usage {
249
+ prompt_tokens: TokenCount::Actual(200),
250
+ completion_tokens: TokenCount::Actual(75),
251
+ total_tokens: TokenCount::Actual(275),
252
+ cached_tokens: TokenCount::Actual(30),
253
+ cost: Some(0.02),
254
+ };
255
+
256
+ let actual = fixture_usage_1.accumulate(&fixture_usage_2);
257
+
258
+ let expected = Usage {
259
+ prompt_tokens: TokenCount::Actual(300),
260
+ completion_tokens: TokenCount::Actual(125),
261
+ total_tokens: TokenCount::Actual(425),
262
+ cached_tokens: TokenCount::Actual(50),
263
+ cost: Some(0.03),
264
+ };
265
+
266
+ assert_eq!(actual, expected);
267
+ }
268
+
269
+ #[test]
270
+ fn test_usage_accumulate_mixed_token_types() {
271
+ let fixture_usage_1 = Usage {
272
+ prompt_tokens: TokenCount::Actual(100),
273
+ completion_tokens: TokenCount::Approx(50),
274
+ total_tokens: TokenCount::Actual(150),
275
+ cached_tokens: TokenCount::Actual(20),
276
+ cost: Some(0.01),
277
+ };
278
+
279
+ let fixture_usage_2 = Usage {
280
+ prompt_tokens: TokenCount::Approx(200),
281
+ completion_tokens: TokenCount::Actual(75),
282
+ total_tokens: TokenCount::Approx(275),
283
+ cached_tokens: TokenCount::Approx(30),
284
+ cost: Some(0.02),
285
+ };
286
+
287
+ let actual = fixture_usage_1.accumulate(&fixture_usage_2);
288
+
289
+ let expected = Usage {
290
+ prompt_tokens: TokenCount::Approx(300),
291
+ completion_tokens: TokenCount::Approx(125),
292
+ total_tokens: TokenCount::Approx(425),
293
+ cached_tokens: TokenCount::Approx(50),
294
+ cost: Some(0.03),
295
+ };
296
+
297
+ assert_eq!(actual, expected);
298
+ }
299
+
300
+ #[test]
301
+ fn test_usage_accumulate_partial_costs() {
302
+ let fixture_usage_1 = Usage {
303
+ prompt_tokens: TokenCount::Actual(100),
304
+ completion_tokens: TokenCount::Actual(50),
305
+ total_tokens: TokenCount::Actual(150),
306
+ cached_tokens: TokenCount::Actual(20),
307
+ cost: Some(0.01),
308
+ };
309
+
310
+ let fixture_usage_2 = Usage {
311
+ prompt_tokens: TokenCount::Actual(200),
312
+ completion_tokens: TokenCount::Actual(75),
313
+ total_tokens: TokenCount::Actual(275),
314
+ cached_tokens: TokenCount::Actual(30),
315
+ cost: None,
316
+ };
317
+
318
+ let actual = fixture_usage_1.accumulate(&fixture_usage_2);
319
+
320
+ let expected = Usage {
321
+ prompt_tokens: TokenCount::Actual(300),
322
+ completion_tokens: TokenCount::Actual(125),
323
+ total_tokens: TokenCount::Actual(425),
324
+ cached_tokens: TokenCount::Actual(50),
325
+ cost: Some(0.01),
326
+ };
327
+
328
+ assert_eq!(actual, expected);
329
+ }
330
+
331
+ #[test]
332
+ fn test_usage_accumulate_no_costs() {
333
+ let fixture_usage_1 = Usage {
334
+ prompt_tokens: TokenCount::Actual(100),
335
+ completion_tokens: TokenCount::Actual(50),
336
+ total_tokens: TokenCount::Actual(150),
337
+ cached_tokens: TokenCount::Actual(20),
338
+ cost: None,
339
+ };
340
+
341
+ let fixture_usage_2 = Usage {
342
+ prompt_tokens: TokenCount::Actual(200),
343
+ completion_tokens: TokenCount::Actual(75),
344
+ total_tokens: TokenCount::Actual(275),
345
+ cached_tokens: TokenCount::Actual(30),
346
+ cost: None,
347
+ };
348
+
349
+ let actual = fixture_usage_1.accumulate(&fixture_usage_2);
350
+
351
+ let expected = Usage {
352
+ prompt_tokens: TokenCount::Actual(300),
353
+ completion_tokens: TokenCount::Actual(125),
354
+ total_tokens: TokenCount::Actual(425),
355
+ cached_tokens: TokenCount::Actual(50),
356
+ cost: None,
357
+ };
358
+
359
+ assert_eq!(actual, expected);
360
+ }
361
+
362
+ #[test]
363
+ fn test_usage_accumulate_with_defaults() {
364
+ let fixture_usage_1 = Usage::default();
365
+
366
+ let fixture_usage_2 = Usage {
367
+ prompt_tokens: TokenCount::Actual(200),
368
+ completion_tokens: TokenCount::Actual(75),
369
+ total_tokens: TokenCount::Actual(275),
370
+ cached_tokens: TokenCount::Actual(30),
371
+ cost: Some(0.05),
372
+ };
373
+
374
+ let actual = fixture_usage_1.accumulate(&fixture_usage_2);
375
+
376
+ let expected = Usage {
377
+ prompt_tokens: TokenCount::Actual(200),
378
+ completion_tokens: TokenCount::Actual(75),
379
+ total_tokens: TokenCount::Actual(275),
380
+ cached_tokens: TokenCount::Actual(30),
381
+ cost: Some(0.05),
382
+ };
383
+
384
+ assert_eq!(actual, expected);
385
+ }
386
+
387
+ #[test]
388
+ fn test_finish_reason_from_str() {
389
+ assert_eq!(
390
+ FinishReason::from_str("length").unwrap(),
391
+ FinishReason::Length
392
+ );
393
+ assert_eq!(
394
+ FinishReason::from_str("content_filter").unwrap(),
395
+ FinishReason::ContentFilter
396
+ );
397
+ assert_eq!(
398
+ FinishReason::from_str("tool_calls").unwrap(),
399
+ FinishReason::ToolCalls
400
+ );
401
+ assert_eq!(FinishReason::from_str("stop").unwrap(), FinishReason::Stop);
402
+ assert_eq!(
403
+ FinishReason::from_str("end_turn").unwrap(),
404
+ FinishReason::Stop
405
+ );
406
+ }
407
+
408
+ #[test]
409
+ fn test_usage_merge_anthropic_cumulative() {
410
+ // Fixture: Simulates Anthropic's message_start + message_delta pattern
411
+ // where output_tokens in message_delta is CUMULATIVE (total), not a delta.
412
+ let fixture_message_start = Usage {
413
+ prompt_tokens: TokenCount::Actual(1000),
414
+ completion_tokens: TokenCount::Actual(1), // Initial output token
415
+ total_tokens: TokenCount::Actual(1001),
416
+ cached_tokens: TokenCount::Actual(300),
417
+ cost: None,
418
+ };
419
+
420
+ let fixture_message_delta = Usage {
421
+ prompt_tokens: TokenCount::Actual(0),
422
+ completion_tokens: TokenCount::Actual(75), // Cumulative total, NOT delta
423
+ total_tokens: TokenCount::Actual(75),
424
+ cached_tokens: TokenCount::Actual(0),
425
+ cost: None,
426
+ };
427
+
428
+ let actual = fixture_message_start.merge(&fixture_message_delta);
429
+
430
+ let expected = Usage {
431
+ prompt_tokens: TokenCount::Actual(1000), // max(1000, 0)
432
+ completion_tokens: TokenCount::Actual(75), // max(1, 75) = 75, NOT 1+75=76
433
+ total_tokens: TokenCount::Actual(1001), // max(1001, 75)
434
+ cached_tokens: TokenCount::Actual(300), // max(300, 0)
435
+ cost: None,
436
+ };
437
+
438
+ assert_eq!(actual, expected);
439
+ }
440
+
441
+ #[test]
442
+ fn test_usage_merge_preserves_costs() {
443
+ let fixture_usage_1 = Usage {
444
+ prompt_tokens: TokenCount::Actual(100),
445
+ completion_tokens: TokenCount::Actual(0),
446
+ total_tokens: TokenCount::Actual(100),
447
+ cached_tokens: TokenCount::Actual(0),
448
+ cost: Some(0.01),
449
+ };
450
+
451
+ let fixture_usage_2 = Usage {
452
+ prompt_tokens: TokenCount::Actual(0),
453
+ completion_tokens: TokenCount::Actual(50),
454
+ total_tokens: TokenCount::Actual(50),
455
+ cached_tokens: TokenCount::Actual(0),
456
+ cost: Some(0.02),
457
+ };
458
+
459
+ let actual = fixture_usage_1.merge(&fixture_usage_2);
460
+
461
+ let expected = Usage {
462
+ prompt_tokens: TokenCount::Actual(100),
463
+ completion_tokens: TokenCount::Actual(50),
464
+ total_tokens: TokenCount::Actual(100),
465
+ cached_tokens: TokenCount::Actual(0),
466
+ cost: Some(0.03), // Costs are summed, not maxed
467
+ };
468
+
469
+ assert_eq!(actual, expected);
470
+ }
471
+ }
crates/forge_domain/src/message_pattern.rs ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde_json::json;
2
+
3
+ use crate::{
4
+ Context, ContextMessage, MessageEntry, ModelId, ToolCallFull, ToolCallId, ToolName, ToolResult,
5
+ };
6
+
7
+ /// Converts a condensed string pattern into a Context with messages.
8
+ ///
9
+ /// This utility type is primarily used in tests to quickly create Context
10
+ /// objects with specific message sequences without verbose setup code.
11
+ ///
12
+ /// # Pattern Format
13
+ ///
14
+ /// Each character in the pattern represents a message with a specific role:
15
+ /// - `'u'` = User message
16
+ /// - `'a'` = Assistant message
17
+ /// - `'s'` = System message
18
+ /// - `'t'` = Assistant message with tool call
19
+ /// - `'r'` = Tool result message
20
+ ///
21
+ /// # Examples
22
+ ///
23
+ /// ```rust,ignore
24
+ /// // Creates: User -> Assistant -> User
25
+ /// let context = MessagePattern::new("uau").build();
26
+ ///
27
+ /// // Creates: System -> System -> User -> System -> User -> System -> User -> System -> Assistant -> Assistant -> System -> Assistant
28
+ /// let context = MessagePattern::new("ssusususaasa").build();
29
+ ///
30
+ /// // Creates: User -> Assistant with tool call -> Tool result -> User
31
+ /// let context = MessagePattern::new("utru").build();
32
+ /// ```
33
+ #[derive(Debug, Clone)]
34
+ pub struct MessagePattern {
35
+ pattern: String,
36
+ }
37
+
38
+ impl MessagePattern {
39
+ /// Creates a new MessagePattern from the given pattern string.
40
+ ///
41
+ /// # Arguments
42
+ ///
43
+ /// * `pattern` - A string where each character represents a message role:
44
+ /// - `'u'` for User
45
+ /// - `'a'` for Assistant
46
+ /// - `'s'` for System
47
+ /// - `'t'` for Assistant with tool call
48
+ /// - `'r'` for Tool result
49
+ pub fn new(pattern: impl Into<String>) -> Self {
50
+ Self { pattern: pattern.into() }
51
+ }
52
+
53
+ /// Builds a Context from the pattern.
54
+ ///
55
+ /// Each message will have content in the format "Message {index}" where
56
+ /// index starts from 1. Tool calls and tool results use predefined test
57
+ /// data.
58
+ ///
59
+ /// # Panics
60
+ ///
61
+ /// Panics if the pattern contains any character other than 'u', 'a', 's',
62
+ /// 't', or 'r'.
63
+ pub fn build(self) -> Context {
64
+ let model_id = ModelId::new("gpt-4");
65
+
66
+ let tool_call = ToolCallFull {
67
+ name: ToolName::new("read"),
68
+ call_id: Some(ToolCallId::new("call_123")),
69
+ arguments: json!({"path": "/test/path"}).into(),
70
+ thought_signature: None,
71
+ };
72
+
73
+ let tool_result = ToolResult::new(ToolName::new("read"))
74
+ .call_id(ToolCallId::new("call_123"))
75
+ .success(json!({"content": "File content"}).to_string());
76
+
77
+ let messages: Vec<MessageEntry> = self
78
+ .pattern
79
+ .chars()
80
+ .enumerate()
81
+ .map(|(i, c)| {
82
+ let content = format!("Message {}", i + 1);
83
+ match c {
84
+ 'u' => ContextMessage::user(&content, Some(model_id.clone())),
85
+ 'a' => ContextMessage::assistant(&content, None, None, None),
86
+ 's' => ContextMessage::system(&content),
87
+ 't' => ContextMessage::assistant(
88
+ &content,
89
+ None,
90
+ None,
91
+ Some(vec![tool_call.clone()]),
92
+ ),
93
+ 'r' => ContextMessage::tool_result(tool_result.clone()),
94
+ _ => {
95
+ panic!("Invalid character '{c}' in pattern. Use 'u', 'a', 's', 't', or 'r'")
96
+ }
97
+ }
98
+ })
99
+ .map(MessageEntry::from)
100
+ .collect();
101
+ Context::default().messages(messages)
102
+ }
103
+ }
104
+
105
+ impl From<&str> for MessagePattern {
106
+ fn from(pattern: &str) -> Self {
107
+ Self::new(pattern)
108
+ }
109
+ }
110
+
111
+ impl From<String> for MessagePattern {
112
+ fn from(pattern: String) -> Self {
113
+ Self::new(pattern)
114
+ }
115
+ }
116
+
117
+ #[cfg(test)]
118
+ mod tests {
119
+ use pretty_assertions::assert_eq;
120
+
121
+ use super::*;
122
+ use crate::{ContextMessage, ModelId, Role, TextMessage};
123
+
124
+ #[test]
125
+ fn test_message_pattern_single_user() {
126
+ let fixture = MessagePattern::new("u");
127
+ let actual = fixture.build();
128
+ let expected = Context::default().messages(vec![
129
+ ContextMessage::Text(
130
+ TextMessage::new(Role::User, "Message 1").model(ModelId::new("gpt-4")),
131
+ )
132
+ .into(),
133
+ ]);
134
+ assert_eq!(actual, expected);
135
+ }
136
+
137
+ #[test]
138
+ fn test_message_pattern_user_assistant_user() {
139
+ let fixture = MessagePattern::new("uau");
140
+ let actual = fixture.build();
141
+ let expected = Context::default().messages(vec![
142
+ ContextMessage::Text(
143
+ TextMessage::new(Role::User, "Message 1").model(ModelId::new("gpt-4")),
144
+ )
145
+ .into(),
146
+ ContextMessage::Text(TextMessage::new(Role::Assistant, "Message 2")).into(),
147
+ ContextMessage::Text(
148
+ TextMessage::new(Role::User, "Message 3").model(ModelId::new("gpt-4")),
149
+ )
150
+ .into(),
151
+ ]);
152
+ assert_eq!(actual, expected);
153
+ }
154
+
155
+ #[test]
156
+ fn test_message_pattern_complex() {
157
+ let fixture = MessagePattern::new("ssusususaasa");
158
+ let actual = fixture.build();
159
+
160
+ assert_eq!(actual.messages.len(), 12);
161
+ assert!(actual.messages[0].has_role(Role::System));
162
+ assert!(actual.messages[1].has_role(Role::System));
163
+ assert!(actual.messages[2].has_role(Role::User));
164
+ assert!(actual.messages[3].has_role(Role::System));
165
+ assert!(actual.messages[4].has_role(Role::User));
166
+ assert!(actual.messages[5].has_role(Role::System));
167
+ assert!(actual.messages[6].has_role(Role::User));
168
+ assert!(actual.messages[7].has_role(Role::System));
169
+ assert!(actual.messages[8].has_role(Role::Assistant));
170
+ assert!(actual.messages[9].has_role(Role::Assistant));
171
+ assert!(actual.messages[10].has_role(Role::System));
172
+ assert!(actual.messages[11].has_role(Role::Assistant));
173
+ }
174
+
175
+ #[test]
176
+ fn test_message_pattern_empty() {
177
+ let fixture = MessagePattern::new("");
178
+ let actual = fixture.build();
179
+ let expected = Context::default();
180
+ assert_eq!(actual, expected);
181
+ }
182
+
183
+ #[test]
184
+ fn test_message_pattern_all_system() {
185
+ let fixture = MessagePattern::new("sss");
186
+ let actual = fixture.build();
187
+
188
+ assert_eq!(actual.messages.len(), 3);
189
+ assert!(actual.messages.iter().all(|m| m.has_role(Role::System)));
190
+ }
191
+
192
+ #[test]
193
+ #[should_panic(expected = "Invalid character 'x' in pattern. Use 'u', 'a', 's', 't', or 'r'")]
194
+ fn test_message_pattern_invalid_character() {
195
+ let fixture = MessagePattern::new("uax");
196
+ fixture.build();
197
+ }
198
+
199
+ #[test]
200
+ fn test_message_pattern_from_str() {
201
+ let fixture = MessagePattern::from("ua");
202
+ let actual = fixture.build();
203
+ assert_eq!(actual.messages.len(), 2);
204
+ }
205
+
206
+ #[test]
207
+ fn test_message_pattern_from_string() {
208
+ let fixture = MessagePattern::from("ua".to_string());
209
+ let actual = fixture.build();
210
+ assert_eq!(actual.messages.len(), 2);
211
+ }
212
+
213
+ #[test]
214
+ fn test_message_pattern_content_numbering() {
215
+ let fixture = MessagePattern::new("uau");
216
+ let actual = fixture.build();
217
+
218
+ assert_eq!(actual.messages[0].content().unwrap(), "Message 1");
219
+ assert_eq!(actual.messages[1].content().unwrap(), "Message 2");
220
+ assert_eq!(actual.messages[2].content().unwrap(), "Message 3");
221
+ }
222
+
223
+ #[test]
224
+ fn test_message_pattern_with_tool_call() {
225
+ let fixture = MessagePattern::new("utr");
226
+ let actual = fixture.build();
227
+
228
+ assert_eq!(actual.messages.len(), 3);
229
+ assert!(actual.messages[0].has_role(Role::User));
230
+ assert!(actual.messages[1].has_role(Role::Assistant));
231
+ assert!(actual.messages[1].has_tool_call());
232
+ assert!(actual.messages[2].has_tool_result());
233
+ }
234
+
235
+ #[test]
236
+ fn test_message_pattern_with_multiple_tool_calls() {
237
+ let fixture = MessagePattern::new("utrtr");
238
+ let actual = fixture.build();
239
+
240
+ assert_eq!(actual.messages.len(), 5);
241
+ assert!(actual.messages[1].has_tool_call());
242
+ assert!(actual.messages[2].has_tool_result());
243
+ assert!(actual.messages[3].has_tool_call());
244
+ assert!(actual.messages[4].has_tool_result());
245
+ }
246
+
247
+ #[test]
248
+ fn test_message_pattern_complex_with_tools() {
249
+ let fixture = MessagePattern::new("sutruaua");
250
+ let actual = fixture.build();
251
+
252
+ assert_eq!(actual.messages.len(), 8);
253
+ assert!(actual.messages[0].has_role(Role::System));
254
+ assert!(actual.messages[1].has_role(Role::User));
255
+ assert!(actual.messages[2].has_tool_call());
256
+ assert!(actual.messages[3].has_tool_result());
257
+ assert!(actual.messages[4].has_role(Role::User));
258
+ assert!(actual.messages[5].has_role(Role::Assistant));
259
+ assert!(actual.messages[6].has_role(Role::User));
260
+ assert!(actual.messages[7].has_role(Role::Assistant));
261
+ }
262
+ }
crates/forge_domain/src/model_config.rs ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use schemars::JsonSchema;
2
+ use serde::{Deserialize, Serialize};
3
+
4
+ use crate::{ModelId, ProviderId};
5
+
6
+ /// Domain-level configuration that pairs a provider with a model.
7
+ ///
8
+ /// Used as the unified payload for [`super::ConfigOperation`] variants that
9
+ /// configure a provider/model pair (session, commit, suggest). Both fields are
10
+ /// required; use `Option<ModelConfig>` at the call-site when the configuration
11
+ /// is optional.
12
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
13
+ pub struct ModelConfig {
14
+ /// The provider ID (e.g. `"anthropic"`).
15
+ pub provider: ProviderId,
16
+
17
+ /// The model ID to use with this provider.
18
+ pub model: ModelId,
19
+ }
20
+
21
+ impl ModelConfig {
22
+ /// Creates a new [`ModelConfig`] with the given provider and model.
23
+ ///
24
+ /// # Arguments
25
+ /// * `provider` - The provider identifier
26
+ /// * `model` - The model identifier
27
+ pub fn new(provider: impl Into<ProviderId>, model: impl Into<ModelId>) -> Self {
28
+ Self { provider: provider.into(), model: model.into() }
29
+ }
30
+ }
crates/forge_domain/src/node.rs ADDED
@@ -0,0 +1,506 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use derive_more::Display;
2
+ use derive_setters::Setters;
3
+ use serde::{Deserialize, Serialize};
4
+ use uuid::Uuid;
5
+
6
+ use crate::WorkspaceId;
7
+
8
+ /// Progress events emitted during workspace indexing
9
+ #[derive(Debug, Clone, PartialEq)]
10
+ pub enum SyncProgress {
11
+ /// Sync operation is starting
12
+ Starting,
13
+ /// A new workspace was created on the server
14
+ WorkspaceCreated {
15
+ /// The ID of the newly created workspace
16
+ workspace_id: WorkspaceId,
17
+ },
18
+ /// Discovering files in the directory
19
+ DiscoveringFiles {
20
+ /// ID of the current workspace
21
+ workspace_id: WorkspaceId,
22
+ /// Path being scanned
23
+ path: std::path::PathBuf,
24
+ },
25
+ /// Files have been discovered in the directory
26
+ FilesDiscovered {
27
+ /// Total number of files found
28
+ count: usize,
29
+ },
30
+ /// Comparing local files with server state
31
+ ComparingFiles {
32
+ /// Number of remote files in the workspace
33
+ remote_files: usize,
34
+ /// Number of local files being compared
35
+ local_files: usize,
36
+ },
37
+ /// Diff computed showing breakdown of changes
38
+ DiffComputed {
39
+ /// Number of files added (new files)
40
+ added: usize,
41
+ /// Number of files deleted (orphaned on server)
42
+ deleted: usize,
43
+ /// Number of files modified (changed files)
44
+ modified: usize,
45
+ },
46
+ /// Syncing files (deleting outdated + uploading new/changed)
47
+ Syncing {
48
+ /// Current progress
49
+ current: usize,
50
+ /// Total number of files to sync
51
+ total: usize,
52
+ },
53
+ /// Sync operation completed successfully
54
+ Completed {
55
+ /// Total number of files in the workspace
56
+ total_files: usize,
57
+ /// Number of files that were uploaded (changed or new)
58
+ uploaded_files: usize,
59
+ /// Number of files that failed to sync
60
+ failed_files: usize,
61
+ },
62
+ }
63
+
64
+ impl SyncProgress {
65
+ /// Returns the progress weight (0-100) for this event.
66
+ pub fn weight(&self) -> Option<u64> {
67
+ match self {
68
+ Self::Syncing { current, total } => {
69
+ let sync_progress = if *total > 0 {
70
+ (*current as f64) / (*total as f64) * 100.0
71
+ } else {
72
+ 0.0
73
+ };
74
+ Some(sync_progress as u64)
75
+ }
76
+ _ => None,
77
+ }
78
+ }
79
+ }
80
+
81
+ /// Stored authentication token for the indexing service (no expiry)
82
+ ///
83
+ /// Associates a user with their indexing service authentication token
84
+ /// obtained from the remote authentication API.
85
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86
+ pub struct WorkspaceAuth {
87
+ /// User ID that owns this authentication
88
+ pub user_id: UserId,
89
+ /// Authentication token (obtained from HTTP API)
90
+ pub token: crate::ApiKey,
91
+ /// When this token was stored locally
92
+ pub created_at: chrono::DateTime<chrono::Utc>,
93
+ }
94
+
95
+ impl From<WorkspaceAuth> for crate::AuthDetails {
96
+ fn from(auth: WorkspaceAuth) -> Self {
97
+ crate::AuthDetails::ApiKey(auth.token)
98
+ }
99
+ }
100
+
101
+ impl WorkspaceAuth {
102
+ /// Create a new indexing auth record
103
+ pub fn new(user_id: UserId, token: crate::ApiKey) -> Self {
104
+ Self { user_id, token, created_at: chrono::Utc::now() }
105
+ }
106
+ }
107
+
108
+ /// File content for upload to workspace server
109
+ ///
110
+ /// Contains the file path (relative to workspace root) and its textual content
111
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112
+ pub struct FileRead {
113
+ /// File path (relative to workspace root)
114
+ pub path: String,
115
+ /// File content as UTF-8 text
116
+ pub content: String,
117
+ }
118
+
119
+ impl FileRead {
120
+ /// Create a new file read entry
121
+ pub fn new(path: String, content: String) -> Self {
122
+ Self { path, content }
123
+ }
124
+ }
125
+
126
+ /// Generic wrapper for workspace operations
127
+ #[derive(Debug, Clone, PartialEq, Eq)]
128
+ pub struct CodeBase<T> {
129
+ pub user_id: UserId,
130
+ pub workspace_id: WorkspaceId,
131
+ pub data: T,
132
+ }
133
+
134
+ impl<T> CodeBase<T> {
135
+ pub fn new(user_id: UserId, workspace_id: WorkspaceId, data: T) -> Self {
136
+ Self { user_id, workspace_id, data }
137
+ }
138
+ }
139
+
140
+ #[derive(Debug, Clone, PartialEq, Eq, Setters)]
141
+ #[setters(strip_option, into)]
142
+ pub struct SearchParams<'a> {
143
+ pub query: &'a str,
144
+ pub limit: Option<usize>,
145
+ pub top_k: Option<u32>,
146
+ pub use_case: String,
147
+ pub starts_with: Option<String>,
148
+ pub ends_with: Option<Vec<String>>,
149
+ }
150
+
151
+ impl<'a> SearchParams<'a> {
152
+ pub fn new(query: &'a str, use_case: &str) -> Self {
153
+ Self {
154
+ query,
155
+ limit: None,
156
+ top_k: None,
157
+ use_case: use_case.to_string(),
158
+ starts_with: None,
159
+ ends_with: None,
160
+ }
161
+ }
162
+ }
163
+
164
+ pub type CodeSearchQuery<'a> = CodeBase<SearchParams<'a>>;
165
+ pub type FileUpload = CodeBase<Vec<FileRead>>;
166
+ pub type FileDeletion = CodeBase<Vec<String>>;
167
+ pub type WorkspaceFiles = CodeBase<()>;
168
+
169
+ /// User identifier for codebase operations.
170
+ ///
171
+ /// Unique per machine, generated once and stored in database.
172
+ #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display)]
173
+ #[display("{}", _0)]
174
+ pub struct UserId(Uuid);
175
+
176
+ impl UserId {
177
+ /// Generate a new random user ID
178
+ pub fn generate() -> Self {
179
+ Self(Uuid::new_v4())
180
+ }
181
+
182
+ /// Parse a user ID from a string
183
+ ///
184
+ /// # Errors
185
+ /// Returns an error if the string is not a valid UUID
186
+ pub fn from_string(s: &str) -> anyhow::Result<Self> {
187
+ Ok(Self(Uuid::parse_str(s)?))
188
+ }
189
+ }
190
+
191
+ /// Node identifier for code graph nodes.
192
+ ///
193
+ /// Uniquely identifies a node in the codebase graph (file chunks, files,
194
+ /// notes, tasks, etc.).
195
+ #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display)]
196
+ #[display("{}", _0)]
197
+ pub struct NodeId(String);
198
+
199
+ impl NodeId {
200
+ /// Create a new node ID from a string
201
+ pub fn new(id: impl Into<String>) -> Self {
202
+ Self(id.into())
203
+ }
204
+
205
+ /// Get the node ID as a string slice
206
+ pub fn as_str(&self) -> &str {
207
+ &self.0
208
+ }
209
+ }
210
+
211
+ impl From<String> for NodeId {
212
+ fn from(s: String) -> Self {
213
+ Self(s)
214
+ }
215
+ }
216
+
217
+ impl From<&str> for NodeId {
218
+ fn from(s: &str) -> Self {
219
+ Self(s.to_string())
220
+ }
221
+ }
222
+
223
+ impl AsRef<str> for NodeId {
224
+ fn as_ref(&self) -> &str {
225
+ &self.0
226
+ }
227
+ }
228
+
229
+ /// Git repository information for a workspace
230
+ ///
231
+ /// Contains commit hash and branch name for version tracking
232
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233
+ pub struct GitInfo {
234
+ /// Git commit hash (e.g., "abc123...")
235
+ pub commit: String,
236
+ /// Git branch name (e.g., "main", "develop")
237
+ pub branch: String,
238
+ }
239
+
240
+ /// Information about a workspace from the server
241
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242
+ pub struct WorkspaceInfo {
243
+ /// Workspace ID
244
+ pub workspace_id: WorkspaceId,
245
+ /// Working directory path
246
+ pub working_dir: String,
247
+ /// Number of nodes created
248
+ pub node_count: Option<u64>,
249
+ /// Number of relations between nodes
250
+ pub relation_count: Option<u64>,
251
+ /// Last updated timestamp
252
+ pub last_updated: Option<chrono::DateTime<chrono::Utc>>,
253
+ /// Workspace created time.
254
+ pub created_at: chrono::DateTime<chrono::Utc>,
255
+ }
256
+
257
+ /// Result of a codebase sync operation
258
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Setters)]
259
+ pub struct FileUploadResponse {
260
+ /// Workspace ID that was synced
261
+ pub workspace_id: WorkspaceId,
262
+ /// Number of files processed
263
+ pub files_processed: usize,
264
+ /// Upload statistics
265
+ pub upload_stats: FileUploadInfo,
266
+ /// Whether a new workspace was created (vs using existing)
267
+ pub is_new_workspace: bool,
268
+ }
269
+
270
+ impl FileUploadResponse {
271
+ /// Create new sync statistics
272
+ pub fn new(
273
+ workspace_id: WorkspaceId,
274
+ files_processed: usize,
275
+ upload_stats: FileUploadInfo,
276
+ ) -> Self {
277
+ Self {
278
+ workspace_id,
279
+ files_processed,
280
+ upload_stats,
281
+ is_new_workspace: false,
282
+ }
283
+ }
284
+ }
285
+
286
+ /// Statistics from uploading files to the codebase server
287
+ #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
288
+ #[non_exhaustive]
289
+ pub struct FileUploadInfo {
290
+ /// Number of code nodes created
291
+ pub nodes_created: usize,
292
+ /// Number of relations created
293
+ pub relations_created: usize,
294
+ }
295
+
296
+ impl std::ops::Add for FileUploadInfo {
297
+ type Output = Self;
298
+
299
+ fn add(self, other: Self) -> Self {
300
+ Self {
301
+ nodes_created: self.nodes_created + other.nodes_created,
302
+ relations_created: self.relations_created + other.relations_created,
303
+ }
304
+ }
305
+ }
306
+
307
+ impl FileUploadInfo {
308
+ /// Create new upload statistics
309
+ pub fn new(nodes_created: usize, relations_created: usize) -> Self {
310
+ Self { nodes_created, relations_created }
311
+ }
312
+ }
313
+
314
+ /// Results for a single codebase search query
315
+ #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
316
+ pub struct CodebaseQueryResult {
317
+ /// The query string that was executed
318
+ pub query: String,
319
+ /// Relevance query used for re-ranking
320
+ pub use_case: String,
321
+ /// The search results for this query
322
+ pub results: Vec<Node>,
323
+ }
324
+
325
+ /// Results for multiple codebase search queries
326
+ #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
327
+ pub struct CodebaseSearchResults {
328
+ /// Results for each query/use_case pair
329
+ pub queries: Vec<CodebaseQueryResult>,
330
+ }
331
+
332
+ /// A search result with its similarity score
333
+ ///
334
+ /// Wraps a code node with its semantic search scores,
335
+ /// keeping the scores separate from the node data itself.
336
+ #[derive(
337
+ Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, derive_setters::Setters,
338
+ )]
339
+ #[setters(strip_option)]
340
+ pub struct Node {
341
+ /// Node identifier
342
+ pub node_id: NodeId,
343
+ /// The node data (file, chunk, note, etc.)
344
+ #[serde(flatten)]
345
+ pub node: NodeData,
346
+ /// Relevance score (most important ranking metric)
347
+ pub relevance: Option<f32>,
348
+ /// Distance score (second ranking metric, lower is better)
349
+ pub distance: Option<f32>,
350
+ }
351
+
352
+ /// File chunk with precise line numbers
353
+ #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
354
+ pub struct FileChunk {
355
+ /// File path
356
+ pub file_path: String,
357
+ /// Code content
358
+ pub content: String,
359
+ /// Start line in the file
360
+ pub start_line: u32,
361
+ /// End line in the file
362
+ pub end_line: u32,
363
+ }
364
+
365
+ /// Full file content
366
+ #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
367
+ pub struct FileNode {
368
+ /// File path
369
+ pub file_path: String,
370
+ /// File content
371
+ pub content: String,
372
+ /// SHA-256 hash of the file content
373
+ pub hash: String,
374
+ }
375
+
376
+ /// File reference (path only, no content)
377
+ #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
378
+ pub struct FileRef {
379
+ /// File path
380
+ pub file_path: String,
381
+ /// SHA-256 hash of the file content
382
+ pub file_hash: String,
383
+ }
384
+
385
+ /// Note content
386
+ #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
387
+ pub struct Note {
388
+ /// Note content
389
+ pub content: String,
390
+ }
391
+
392
+ /// Task description
393
+ #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
394
+ pub struct Task {
395
+ /// Task description
396
+ pub task: String,
397
+ }
398
+
399
+ /// Result of a semantic search query
400
+ ///
401
+ /// Represents different types of nodes returned from the codebase service.
402
+ /// Each variant contains only the fields relevant to that node type.
403
+ #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, derive_more::From)]
404
+ #[serde(tag = "type", rename_all = "snake_case")]
405
+ pub enum NodeData {
406
+ /// File chunk with precise line numbers
407
+ #[from]
408
+ FileChunk(FileChunk),
409
+ /// Full file content
410
+ #[from]
411
+ File(FileNode),
412
+ /// File reference (path only, no content)
413
+ #[from]
414
+ FileRef(FileRef),
415
+ /// Note content
416
+ #[from]
417
+ Note(Note),
418
+ /// Task description
419
+ #[from]
420
+ Task(Task),
421
+ }
422
+
423
+ #[cfg(test)]
424
+ mod tests {
425
+ use pretty_assertions::assert_eq;
426
+
427
+ use super::*;
428
+
429
+ #[test]
430
+ fn test_user_id_roundtrip() {
431
+ let user_id = UserId::generate();
432
+ let s = user_id.to_string();
433
+ let parsed = UserId::from_string(&s).unwrap();
434
+ assert_eq!(user_id, parsed);
435
+ }
436
+
437
+ #[test]
438
+ fn test_workspace_id_roundtrip() {
439
+ let workspace_id = WorkspaceId::generate();
440
+ let s = workspace_id.to_string();
441
+ let parsed = WorkspaceId::from_string(&s).unwrap();
442
+ assert_eq!(workspace_id, parsed);
443
+ }
444
+
445
+ #[test]
446
+ fn test_search_params_with_file_extension() {
447
+ let actual = SearchParams::new("retry mechanism", "find retry logic")
448
+ .limit(10usize)
449
+ .top_k(20u32)
450
+ .ends_with(vec![".rs".to_string()]);
451
+
452
+ let expected = SearchParams {
453
+ query: "retry mechanism",
454
+ limit: Some(10),
455
+ top_k: Some(20),
456
+ use_case: "find retry logic".to_string(),
457
+ starts_with: None,
458
+ ends_with: Some(vec![".rs".to_string()]),
459
+ };
460
+
461
+ assert_eq!(actual, expected);
462
+ }
463
+
464
+ #[test]
465
+ fn test_search_params_with_multiple_file_extensions() {
466
+ let actual = SearchParams::new("retry mechanism", "find retry logic")
467
+ .limit(10usize)
468
+ .top_k(20u32)
469
+ .ends_with(vec![
470
+ ".rs".to_string(),
471
+ ".ts".to_string(),
472
+ ".py".to_string(),
473
+ ]);
474
+
475
+ let expected = SearchParams {
476
+ query: "retry mechanism",
477
+ limit: Some(10),
478
+ top_k: Some(20),
479
+ use_case: "find retry logic".to_string(),
480
+ starts_with: None,
481
+ ends_with: Some(vec![
482
+ ".rs".to_string(),
483
+ ".ts".to_string(),
484
+ ".py".to_string(),
485
+ ]),
486
+ };
487
+
488
+ assert_eq!(actual, expected);
489
+ }
490
+
491
+ #[test]
492
+ fn test_search_params_without_file_extension() {
493
+ let actual = SearchParams::new("auth logic", "authentication implementation").limit(5usize);
494
+
495
+ let expected = SearchParams {
496
+ query: "auth logic",
497
+ limit: Some(5),
498
+ top_k: None,
499
+ use_case: "authentication implementation".to_string(),
500
+ starts_with: None,
501
+ ends_with: None,
502
+ };
503
+
504
+ assert_eq!(actual, expected);
505
+ }
506
+ }
crates/forge_domain/src/provider.rs ADDED
@@ -0,0 +1,1013 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::borrow::Cow;
2
+
3
+ use derive_more::{AsRef, Deref, From};
4
+ use schemars::JsonSchema;
5
+ use serde::{Deserialize, Serialize};
6
+ use strum_macros::{Display, EnumString};
7
+ use url::Url;
8
+
9
+ use crate::{ApiKey, AuthCredential, AuthDetails, Model, Template};
10
+
11
+ /// Distinguishes between different categories of providers
12
+ #[derive(
13
+ Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString, Default,
14
+ )]
15
+ #[serde(rename_all = "snake_case")]
16
+ #[strum(serialize_all = "snake_case")]
17
+ pub enum ProviderType {
18
+ /// LLM providers for chat completions (default for backward compatibility)
19
+ #[default]
20
+ Llm,
21
+ /// Context engine providers for code indexing and search
22
+ ContextEngine,
23
+ }
24
+
25
+ /// --- IMPORTANT ---
26
+ /// The order of providers is important because that would be order in which the
27
+ /// providers will be resolved
28
+ #[derive(
29
+ Debug,
30
+ Clone,
31
+ PartialEq,
32
+ Eq,
33
+ Hash,
34
+ PartialOrd,
35
+ Ord,
36
+ JsonSchema,
37
+ AsRef,
38
+ Deref,
39
+ Serialize,
40
+ Deserialize,
41
+ )]
42
+ #[schemars(with = "String")]
43
+ #[serde(from = "String")]
44
+ pub struct ProviderId(Cow<'static, str>);
45
+
46
+ impl ProviderId {
47
+ // Built-in provider constants
48
+ pub const FORGE: ProviderId = ProviderId(Cow::Borrowed("forge"));
49
+ pub const OPENAI: ProviderId = ProviderId(Cow::Borrowed("openai"));
50
+ pub const OPEN_ROUTER: ProviderId = ProviderId(Cow::Borrowed("open_router"));
51
+ pub const REQUESTY: ProviderId = ProviderId(Cow::Borrowed("requesty"));
52
+ pub const ZAI: ProviderId = ProviderId(Cow::Borrowed("zai"));
53
+ pub const ZAI_CODING: ProviderId = ProviderId(Cow::Borrowed("zai_coding"));
54
+ pub const CEREBRAS: ProviderId = ProviderId(Cow::Borrowed("cerebras"));
55
+ pub const XAI: ProviderId = ProviderId(Cow::Borrowed("xai"));
56
+ pub const ANTHROPIC: ProviderId = ProviderId(Cow::Borrowed("anthropic"));
57
+ pub const CLAUDE_CODE: ProviderId = ProviderId(Cow::Borrowed("claude_code"));
58
+ pub const VERTEX_AI: ProviderId = ProviderId(Cow::Borrowed("vertex_ai"));
59
+ pub const VERTEX_AI_ANTHROPIC: ProviderId = ProviderId(Cow::Borrowed("vertex_ai_anthropic"));
60
+ pub const BIG_MODEL: ProviderId = ProviderId(Cow::Borrowed("big_model"));
61
+ pub const AZURE: ProviderId = ProviderId(Cow::Borrowed("azure"));
62
+ pub const GITHUB_COPILOT: ProviderId = ProviderId(Cow::Borrowed("github_copilot"));
63
+ pub const OPENAI_COMPATIBLE: ProviderId = ProviderId(Cow::Borrowed("openai_compatible"));
64
+ pub const OPENAI_RESPONSES_COMPATIBLE: ProviderId =
65
+ ProviderId(Cow::Borrowed("openai_responses_compatible"));
66
+ pub const ANTHROPIC_COMPATIBLE: ProviderId = ProviderId(Cow::Borrowed("anthropic_compatible"));
67
+ pub const FORGE_SERVICES: ProviderId = ProviderId(Cow::Borrowed("forge_services"));
68
+ pub const IO_INTELLIGENCE: ProviderId = ProviderId(Cow::Borrowed("io_intelligence"));
69
+ pub const BEDROCK: ProviderId = ProviderId(Cow::Borrowed("bedrock"));
70
+ pub const MINIMAX: ProviderId = ProviderId(Cow::Borrowed("minimax"));
71
+ pub const CODEX: ProviderId = ProviderId(Cow::Borrowed("codex"));
72
+ pub const OPENCODE_ZEN: ProviderId = ProviderId(Cow::Borrowed("opencode_zen"));
73
+ pub const OPENCODE_GO: ProviderId = ProviderId(Cow::Borrowed("opencode_go"));
74
+ pub const FIREWORKS_AI: ProviderId = ProviderId(Cow::Borrowed("fireworks-ai"));
75
+ pub const FIREWORKS_AI_FIREPASS: ProviderId =
76
+ ProviderId(Cow::Borrowed("fireworks-ai-firepass"));
77
+ pub const NOVITA: ProviderId = ProviderId(Cow::Borrowed("novita"));
78
+ pub const VIVGRID: ProviderId = ProviderId(Cow::Borrowed("vivgrid"));
79
+ pub const GOOGLE_AI_STUDIO: ProviderId = ProviderId(Cow::Borrowed("google_ai_studio"));
80
+ pub const MODAL: ProviderId = ProviderId(Cow::Borrowed("modal"));
81
+ pub const ADAL: ProviderId = ProviderId(Cow::Borrowed("adal"));
82
+ pub const XIAOMI_MIMO: ProviderId = ProviderId(Cow::Borrowed("xiaomi_mimo"));
83
+ pub const NVIDIA: ProviderId = ProviderId(Cow::Borrowed("nvidia"));
84
+ pub const AMBIENT: ProviderId = ProviderId(Cow::Borrowed("ambient"));
85
+ pub const NEURALWATT: ProviderId = ProviderId(Cow::Borrowed("neuralwatt"));
86
+ pub const ORCA_ROUTER: ProviderId = ProviderId(Cow::Borrowed("orca_router"));
87
+ pub const META: ProviderId = ProviderId(Cow::Borrowed("meta"));
88
+ pub const KIMI_CODING: ProviderId = ProviderId(Cow::Borrowed("kimi_coding"));
89
+ pub const MOONSHOT: ProviderId = ProviderId(Cow::Borrowed("moonshot"));
90
+ pub const ALIBABA_TOKEN_PLAN: ProviderId = ProviderId(Cow::Borrowed("alibaba_token_plan"));
91
+
92
+ /// Returns all built-in provider IDs
93
+ ///
94
+ /// This includes all providers defined as constants in this implementation.
95
+ pub fn built_in_providers() -> &'static [ProviderId] {
96
+ &[
97
+ ProviderId::FORGE,
98
+ ProviderId::OPENAI,
99
+ ProviderId::OPEN_ROUTER,
100
+ ProviderId::REQUESTY,
101
+ ProviderId::ZAI,
102
+ ProviderId::ZAI_CODING,
103
+ ProviderId::CEREBRAS,
104
+ ProviderId::XAI,
105
+ ProviderId::ANTHROPIC,
106
+ ProviderId::CLAUDE_CODE,
107
+ ProviderId::VERTEX_AI,
108
+ ProviderId::VERTEX_AI_ANTHROPIC,
109
+ ProviderId::BIG_MODEL,
110
+ ProviderId::AZURE,
111
+ ProviderId::GITHUB_COPILOT,
112
+ ProviderId::OPENAI_COMPATIBLE,
113
+ ProviderId::OPENAI_RESPONSES_COMPATIBLE,
114
+ ProviderId::ANTHROPIC_COMPATIBLE,
115
+ ProviderId::FORGE_SERVICES,
116
+ ProviderId::IO_INTELLIGENCE,
117
+ ProviderId::BEDROCK,
118
+ ProviderId::MINIMAX,
119
+ ProviderId::CODEX,
120
+ ProviderId::OPENCODE_ZEN,
121
+ ProviderId::OPENCODE_GO,
122
+ ProviderId::FIREWORKS_AI,
123
+ ProviderId::FIREWORKS_AI_FIREPASS,
124
+ ProviderId::NOVITA,
125
+ ProviderId::VIVGRID,
126
+ ProviderId::GOOGLE_AI_STUDIO,
127
+ ProviderId::MODAL,
128
+ ProviderId::ADAL,
129
+ ProviderId::XIAOMI_MIMO,
130
+ ProviderId::NVIDIA,
131
+ ProviderId::AMBIENT,
132
+ ProviderId::NEURALWATT,
133
+ ProviderId::ORCA_ROUTER,
134
+ ProviderId::META,
135
+ ProviderId::KIMI_CODING,
136
+ ProviderId::MOONSHOT,
137
+ ProviderId::ALIBABA_TOKEN_PLAN,
138
+ ]
139
+ }
140
+
141
+ /// Returns the display name for UI (UpperCamelCase with special handling
142
+ /// for acronyms).
143
+ ///
144
+ /// This converts snake_case IDs to proper display names:
145
+ /// - "openai" -> "OpenAI"
146
+ /// - "open_router" -> "OpenRouter"
147
+ /// - "xai" -> "XAI"
148
+ fn display_name(&self) -> String {
149
+ // Special cases for known providers with acronyms
150
+ match self.0.as_ref() {
151
+ "openai" => "OpenAI".to_string(),
152
+ "xai" => "XAI".to_string(),
153
+ "zai" => "ZAI".to_string(),
154
+ "vertex_ai" => "VertexAI".to_string(),
155
+ "vertex_ai_anthropic" => "VertexAIAnthropic".to_string(),
156
+ "openai_compatible" => "OpenAICompatible".to_string(),
157
+ "openai_responses_compatible" => "OpenAIResponsesCompatible".to_string(),
158
+ "io_intelligence" => "IOIntelligence".to_string(),
159
+ "minimax" => "MiniMax".to_string(),
160
+ "codex" => "Codex".to_string(),
161
+ "opencode_zen" => "OpenCode Zen".to_string(),
162
+ "opencode_go" => "OpenCode Go".to_string(),
163
+ "fireworks-ai" => "FireworksAI".to_string(),
164
+ "fireworks-ai-firepass" => "FireworksAIFirepass".to_string(),
165
+ "novita" => "Novita".to_string(),
166
+ "vivgrid" => "Vivgrid".to_string(),
167
+ "google_ai_studio" => "GoogleAIStudio".to_string(),
168
+ "modal" => "Modal".to_string(),
169
+ "adal" => "AdaL".to_string(),
170
+ "xiaomi_mimo" => "XiaomiMimo".to_string(),
171
+ "nvidia" => "NVIDIA".to_string(),
172
+ "ambient" => "Ambient".to_string(),
173
+ "neuralwatt" => "Neuralwatt".to_string(),
174
+ "orca_router" => "OrcaRouter".to_string(),
175
+ "meta" => "Meta".to_string(),
176
+ _ => {
177
+ // For other providers, use UpperCamelCase conversion
178
+ use convert_case::{Case, Casing};
179
+ self.0.to_case(Case::UpperCamel)
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ impl std::fmt::Display for ProviderId {
186
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187
+ write!(f, "{}", self.display_name())
188
+ }
189
+ }
190
+
191
+ impl std::str::FromStr for ProviderId {
192
+ type Err = std::convert::Infallible;
193
+
194
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
195
+ // Check if it's a built-in provider first
196
+ let provider = match s {
197
+ "forge" => ProviderId::FORGE,
198
+ "openai" => ProviderId::OPENAI,
199
+ "open_router" => ProviderId::OPEN_ROUTER,
200
+ "requesty" => ProviderId::REQUESTY,
201
+ "zai" => ProviderId::ZAI,
202
+ "zai_coding" => ProviderId::ZAI_CODING,
203
+ "cerebras" => ProviderId::CEREBRAS,
204
+ "xai" => ProviderId::XAI,
205
+ "anthropic" => ProviderId::ANTHROPIC,
206
+ "claude_code" => ProviderId::CLAUDE_CODE,
207
+ "vertex_ai" => ProviderId::VERTEX_AI,
208
+ "big_model" => ProviderId::BIG_MODEL,
209
+ "azure" => ProviderId::AZURE,
210
+ "github_copilot" => ProviderId::GITHUB_COPILOT,
211
+ "openai_compatible" => ProviderId::OPENAI_COMPATIBLE,
212
+ "openai_responses_compatible" => ProviderId::OPENAI_RESPONSES_COMPATIBLE,
213
+ "anthropic_compatible" => ProviderId::ANTHROPIC_COMPATIBLE,
214
+ "forge_services" => ProviderId::FORGE_SERVICES,
215
+ "io_intelligence" => ProviderId::IO_INTELLIGENCE,
216
+ "minimax" => ProviderId::MINIMAX,
217
+ "codex" => ProviderId::CODEX,
218
+ "opencode_go" => ProviderId::OPENCODE_GO,
219
+ "fireworks-ai" => ProviderId::FIREWORKS_AI,
220
+ "fireworks-ai-firepass" => ProviderId::FIREWORKS_AI_FIREPASS,
221
+ "novita" => ProviderId::NOVITA,
222
+ "vertex_ai_anthropic" => ProviderId::VERTEX_AI_ANTHROPIC,
223
+ "bedrock" => ProviderId::BEDROCK,
224
+ "opencode_zen" => ProviderId::OPENCODE_ZEN,
225
+ "vivgrid" => ProviderId::VIVGRID,
226
+ "google_ai_studio" => ProviderId::GOOGLE_AI_STUDIO,
227
+ "modal" => ProviderId::MODAL,
228
+ "adal" => ProviderId::ADAL,
229
+ "xiaomi_mimo" => ProviderId::XIAOMI_MIMO,
230
+ "nvidia" => ProviderId::NVIDIA,
231
+ "ambient" => ProviderId::AMBIENT,
232
+ "neuralwatt" => ProviderId::NEURALWATT,
233
+ "orca_router" => ProviderId::ORCA_ROUTER,
234
+ "meta" => ProviderId::META,
235
+ "kimi_coding" => ProviderId::KIMI_CODING,
236
+ "moonshot" => ProviderId::MOONSHOT,
237
+ "alibaba_token_plan" => ProviderId::ALIBABA_TOKEN_PLAN,
238
+ // For custom providers, use Cow::Owned to avoid memory leaks
239
+ custom => ProviderId(Cow::Owned(custom.to_string())),
240
+ };
241
+ Ok(provider)
242
+ }
243
+ }
244
+
245
+ impl From<String> for ProviderId {
246
+ fn from(s: String) -> Self {
247
+ std::str::FromStr::from_str(&s).unwrap()
248
+ }
249
+ }
250
+
251
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
252
+ pub enum ProviderResponse {
253
+ OpenAI,
254
+ OpenAIResponses,
255
+ Anthropic,
256
+ Bedrock,
257
+ Google,
258
+ OpenCode,
259
+ }
260
+
261
+ /// Represents the source of models for a provider
262
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263
+ #[serde(untagged)]
264
+ pub enum ModelSource<T> {
265
+ /// Can be a `Url` or a `Template`
266
+ Url(T),
267
+ Hardcoded(Vec<Model>),
268
+ }
269
+
270
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271
+ pub struct Provider<T> {
272
+ pub id: ProviderId,
273
+ #[serde(default)]
274
+ pub provider_type: ProviderType,
275
+ pub response: Option<ProviderResponse>,
276
+ pub url: T,
277
+ pub models: Option<ModelSource<T>>,
278
+ pub auth_methods: Vec<crate::AuthMethod>,
279
+ #[serde(default)]
280
+ pub url_params: Vec<crate::URLParamSpec>,
281
+ pub credential: Option<AuthCredential>,
282
+ /// Custom HTTP headers to include in API requests for this provider.
283
+ #[serde(default, skip_serializing_if = "Option::is_none")]
284
+ pub custom_headers: Option<std::collections::HashMap<String, String>>,
285
+ }
286
+
287
+ /// Type alias for a provider with template URLs (not yet rendered)
288
+ pub type ProviderTemplate = Provider<Template<crate::URLParameters>>;
289
+
290
+ impl<T> Provider<T> {
291
+ pub fn is_configured(&self) -> bool {
292
+ self.credential.is_some()
293
+ }
294
+ pub fn models(&self) -> Option<&ModelSource<T>> {
295
+ self.models.as_ref()
296
+ }
297
+ }
298
+
299
+ impl Provider<Url> {
300
+ pub fn url(&self) -> &Url {
301
+ &self.url
302
+ }
303
+
304
+ pub fn api_key(&self) -> Option<&ApiKey> {
305
+ self.credential
306
+ .as_ref()
307
+ .and_then(|c| match &c.auth_details {
308
+ AuthDetails::ApiKey(key) => Some(key),
309
+ _ => None,
310
+ })
311
+ }
312
+ }
313
+
314
+ /// Enum for viewing providers in listings where both configured and
315
+ /// unconfigured.
316
+ #[derive(Debug, Clone, PartialEq, From)]
317
+ pub enum AnyProvider {
318
+ Url(Provider<Url>),
319
+ Template(ProviderTemplate),
320
+ }
321
+
322
+ impl AnyProvider {
323
+ /// Returns whether this provider is configured
324
+ pub fn is_configured(&self) -> bool {
325
+ match self {
326
+ AnyProvider::Url(p) => p.is_configured(),
327
+ AnyProvider::Template(p) => p.is_configured(),
328
+ }
329
+ }
330
+
331
+ pub fn provider_type(&self) -> &ProviderType {
332
+ match self {
333
+ AnyProvider::Url(p) => &p.provider_type,
334
+ AnyProvider::Template(t) => &t.provider_type,
335
+ }
336
+ }
337
+
338
+ pub fn id(&self) -> ProviderId {
339
+ match self {
340
+ AnyProvider::Url(p) => p.id.clone(),
341
+ AnyProvider::Template(p) => p.id.clone(),
342
+ }
343
+ }
344
+
345
+ /// Gets the response type
346
+ pub fn response(&self) -> Option<&ProviderResponse> {
347
+ match self {
348
+ AnyProvider::Url(p) => p.response.as_ref(),
349
+ AnyProvider::Template(p) => p.response.as_ref(),
350
+ }
351
+ }
352
+
353
+ /// Gets the URL for this provider.
354
+ ///
355
+ /// For configured providers, returns the resolved URL. For template
356
+ /// providers with no URL parameters (i.e. a hardcoded default URL in
357
+ /// provider.json), parses and returns the template string as a URL.
358
+ /// Returns `None` for template providers that require user-supplied URL
359
+ /// parameters.
360
+ pub fn url(&self) -> Option<Url> {
361
+ match self {
362
+ AnyProvider::Url(p) => Some(p.url().clone()),
363
+ AnyProvider::Template(t) if t.url_params.is_empty() => Url::parse(&t.url.template).ok(),
364
+ AnyProvider::Template(_) => None,
365
+ }
366
+ }
367
+ pub fn url_params(&self) -> &[crate::URLParamSpec] {
368
+ match self {
369
+ AnyProvider::Url(p) => &p.url_params,
370
+ AnyProvider::Template(p) => &p.url_params,
371
+ }
372
+ }
373
+
374
+ /// Gets the authentication methods supported by this provider
375
+ pub fn auth_methods(&self) -> &[crate::AuthMethod] {
376
+ match self {
377
+ AnyProvider::Url(p) => &p.auth_methods,
378
+ AnyProvider::Template(p) => &p.auth_methods,
379
+ }
380
+ }
381
+
382
+ /// Consumes self and returns the configured provider if this is a URL
383
+ /// provider with credentials
384
+ pub fn into_configured(self) -> Option<Provider<Url>> {
385
+ match self {
386
+ AnyProvider::Url(p) if p.is_configured() => Some(p),
387
+ _ => None,
388
+ }
389
+ }
390
+ }
391
+
392
+ /// Represents a provider with its available models
393
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
394
+ pub struct ProviderModels {
395
+ /// The provider identifier
396
+ pub provider_id: ProviderId,
397
+ /// Available models from this provider
398
+ pub models: Vec<Model>,
399
+ }
400
+
401
+ #[cfg(test)]
402
+ mod test_helpers {
403
+ use std::collections::HashMap;
404
+
405
+ use super::*;
406
+
407
+ fn make_credential(provider_id: ProviderId, key: &str) -> Option<AuthCredential> {
408
+ Some(AuthCredential {
409
+ id: provider_id,
410
+ auth_details: AuthDetails::ApiKey(ApiKey::from(key.to_string())),
411
+ url_params: HashMap::new(),
412
+ })
413
+ }
414
+
415
+ /// Test helper for creating a ZAI provider
416
+ pub(super) fn zai(key: &str) -> Provider<Url> {
417
+ Provider {
418
+ id: ProviderId::ZAI,
419
+ provider_type: Default::default(),
420
+ response: Some(ProviderResponse::OpenAI),
421
+ url: Url::parse("https://api.z.ai/api/paas/v4/chat/completions").unwrap(),
422
+ auth_methods: vec![crate::AuthMethod::ApiKey],
423
+ url_params: vec![],
424
+ credential: make_credential(ProviderId::ZAI, key),
425
+ custom_headers: None,
426
+ models: Some(ModelSource::Url(
427
+ Url::parse("https://api.z.ai/api/paas/v4/models").unwrap(),
428
+ )),
429
+ }
430
+ }
431
+
432
+ /// Test helper for creating a ZAI Coding provider
433
+ pub(super) fn zai_coding(key: &str) -> Provider<Url> {
434
+ Provider {
435
+ id: ProviderId::ZAI_CODING,
436
+ provider_type: Default::default(),
437
+ response: Some(ProviderResponse::OpenAI),
438
+ url: Url::parse("https://api.z.ai/api/coding/paas/v4/chat/completions").unwrap(),
439
+ auth_methods: vec![crate::AuthMethod::ApiKey],
440
+ url_params: vec![],
441
+ credential: make_credential(ProviderId::ZAI_CODING, key),
442
+ custom_headers: None,
443
+ models: Some(ModelSource::Url(
444
+ Url::parse("https://api.z.ai/api/paas/v4/models").unwrap(),
445
+ )),
446
+ }
447
+ }
448
+
449
+ /// Test helper for creating an OpenAI provider
450
+ pub(super) fn openai(key: &str) -> Provider<Url> {
451
+ Provider {
452
+ id: ProviderId::OPENAI,
453
+ provider_type: Default::default(),
454
+ response: Some(ProviderResponse::OpenAI),
455
+ url: Url::parse("https://api.openai.com/v1/chat/completions").unwrap(),
456
+ auth_methods: vec![crate::AuthMethod::ApiKey],
457
+ url_params: vec![],
458
+ credential: make_credential(ProviderId::OPENAI, key),
459
+ custom_headers: None,
460
+ models: Some(ModelSource::Url(
461
+ Url::parse("https://api.openai.com/v1/models").unwrap(),
462
+ )),
463
+ }
464
+ }
465
+
466
+ /// Test helper for creating an XAI provider
467
+ pub(super) fn xai(key: &str) -> Provider<Url> {
468
+ Provider {
469
+ id: ProviderId::XAI,
470
+ provider_type: Default::default(),
471
+ response: Some(ProviderResponse::OpenAI),
472
+ url: Url::parse("https://api.x.ai/v1/chat/completions").unwrap(),
473
+ auth_methods: vec![crate::AuthMethod::ApiKey],
474
+ url_params: vec![],
475
+ credential: make_credential(ProviderId::XAI, key),
476
+ custom_headers: None,
477
+ models: Some(ModelSource::Url(
478
+ Url::parse("https://api.x.ai/v1/models").unwrap(),
479
+ )),
480
+ }
481
+ }
482
+
483
+ /// Test helper for creating a Vertex AI provider
484
+ pub(super) fn vertex_ai(key: &str, project_id: &str, location: &str) -> Provider<Url> {
485
+ let (chat_url, model_url) = if location == "global" {
486
+ (
487
+ format!(
488
+ "https://aiplatform.googleapis.com/v1/projects/{}/locations/{}/endpoints/openapi/chat/completions",
489
+ project_id, location
490
+ ),
491
+ format!(
492
+ "https://aiplatform.googleapis.com/v1/projects/{}/locations/{}/endpoints/openapi/models",
493
+ project_id, location
494
+ ),
495
+ )
496
+ } else {
497
+ (
498
+ format!(
499
+ "https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/endpoints/openapi/chat/completions",
500
+ location, project_id, location
501
+ ),
502
+ format!(
503
+ "https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/endpoints/openapi/models",
504
+ location, project_id, location
505
+ ),
506
+ )
507
+ };
508
+ Provider {
509
+ id: ProviderId::VERTEX_AI,
510
+ provider_type: Default::default(),
511
+ response: Some(ProviderResponse::OpenAI),
512
+ url: Url::parse(&chat_url).unwrap(),
513
+ auth_methods: vec![crate::AuthMethod::ApiKey],
514
+ url_params: ["project_id", "location"]
515
+ .iter()
516
+ .map(|&s| s.to_string().into())
517
+ .collect(),
518
+ credential: make_credential(ProviderId::VERTEX_AI, key),
519
+ custom_headers: None,
520
+ models: Some(ModelSource::Url(Url::parse(&model_url).unwrap())),
521
+ }
522
+ }
523
+
524
+ /// Test helper for creating an IO Intelligence provider
525
+ pub(super) fn io_intelligence(key: &str) -> Provider<Url> {
526
+ Provider {
527
+ id: ProviderId::IO_INTELLIGENCE,
528
+ provider_type: Default::default(),
529
+ response: Some(ProviderResponse::OpenAI),
530
+ url: Url::parse("https://api.intelligence.io.solutions/api/v1/chat/completions")
531
+ .unwrap(),
532
+ auth_methods: vec![crate::AuthMethod::ApiKey],
533
+ url_params: vec![],
534
+ credential: make_credential(ProviderId::IO_INTELLIGENCE, key),
535
+ custom_headers: None,
536
+ models: Some(ModelSource::Url(
537
+ Url::parse("https://api.intelligence.io.solutions/api/v1/models").unwrap(),
538
+ )),
539
+ }
540
+ }
541
+
542
+ /// Test helper for creating an Azure provider
543
+ pub(super) fn azure(
544
+ key: &str,
545
+ resource_name: &str,
546
+ deployment_name: &str,
547
+ api_version: &str,
548
+ ) -> Provider<Url> {
549
+ let chat_url = format!(
550
+ "https://{}.openai.azure.com/openai/deployments/{}/chat/completions?api-version={}",
551
+ resource_name, deployment_name, api_version
552
+ );
553
+ let model_url = format!(
554
+ "https://{}.openai.azure.com/openai/models?api-version={}",
555
+ resource_name, api_version
556
+ );
557
+
558
+ Provider {
559
+ id: ProviderId::AZURE,
560
+ provider_type: Default::default(),
561
+ response: Some(ProviderResponse::OpenAI),
562
+ url: Url::parse(&chat_url).unwrap(),
563
+ auth_methods: vec![crate::AuthMethod::ApiKey],
564
+ url_params: ["resource_name", "deployment_name", "api_version"]
565
+ .iter()
566
+ .map(|&s| s.to_string().into())
567
+ .collect(),
568
+ credential: make_credential(ProviderId::AZURE, key),
569
+ custom_headers: None,
570
+ models: Some(ModelSource::Url(Url::parse(&model_url).unwrap())),
571
+ }
572
+ }
573
+ }
574
+
575
+ #[cfg(test)]
576
+ mod tests {
577
+ use std::collections::HashMap;
578
+ use std::str::FromStr;
579
+
580
+ use pretty_assertions::assert_eq;
581
+
582
+ use super::test_helpers::*;
583
+ use super::*;
584
+
585
+ #[test]
586
+ fn test_provider_id_display_name() {
587
+ assert_eq!(ProviderId::OPENAI.to_string(), "OpenAI");
588
+ assert_eq!(ProviderId::OPEN_ROUTER.to_string(), "OpenRouter");
589
+ assert_eq!(ProviderId::ZAI.to_string(), "ZAI");
590
+ assert_eq!(ProviderId::XAI.to_string(), "XAI");
591
+ assert_eq!(ProviderId::ANTHROPIC.to_string(), "Anthropic");
592
+ assert_eq!(ProviderId::GITHUB_COPILOT.to_string(), "GithubCopilot");
593
+ assert_eq!(ProviderId::VERTEX_AI.to_string(), "VertexAI");
594
+ assert_eq!(
595
+ ProviderId::OPENAI_COMPATIBLE.to_string(),
596
+ "OpenAICompatible"
597
+ );
598
+ assert_eq!(
599
+ ProviderId::OPENAI_RESPONSES_COMPATIBLE.to_string(),
600
+ "OpenAIResponsesCompatible"
601
+ );
602
+ assert_eq!(
603
+ ProviderId::ANTHROPIC_COMPATIBLE.to_string(),
604
+ "AnthropicCompatible"
605
+ );
606
+ assert_eq!(ProviderId::IO_INTELLIGENCE.to_string(), "IOIntelligence");
607
+ assert_eq!(ProviderId::CODEX.to_string(), "Codex");
608
+ assert_eq!(ProviderId::FIREWORKS_AI.to_string(), "FireworksAI");
609
+ assert_eq!(ProviderId::VIVGRID.to_string(), "Vivgrid");
610
+ assert_eq!(ProviderId::OPENCODE_ZEN.to_string(), "OpenCode Zen");
611
+ assert_eq!(ProviderId::OPENCODE_GO.to_string(), "OpenCode Go");
612
+ assert_eq!(ProviderId::GOOGLE_AI_STUDIO.to_string(), "GoogleAIStudio");
613
+ assert_eq!(ProviderId::NVIDIA.to_string(), "NVIDIA");
614
+ assert_eq!(ProviderId::AMBIENT.to_string(), "Ambient");
615
+ assert_eq!(ProviderId::ORCA_ROUTER.to_string(), "OrcaRouter");
616
+ assert_eq!(ProviderId::META.to_string(), "Meta");
617
+ }
618
+
619
+ #[test]
620
+ fn test_codex_from_str() {
621
+ let actual = ProviderId::from_str("codex").unwrap();
622
+ let expected = ProviderId::CODEX;
623
+ assert_eq!(actual, expected);
624
+ }
625
+
626
+ #[test]
627
+ fn test_fireworks_ai_from_str() {
628
+ let actual = ProviderId::from_str("fireworks-ai").unwrap();
629
+ let expected = ProviderId::FIREWORKS_AI;
630
+ assert_eq!(actual, expected);
631
+ }
632
+
633
+ #[test]
634
+ fn test_vivgrid_from_str() {
635
+ let actual = ProviderId::from_str("vivgrid").unwrap();
636
+ let expected = ProviderId::VIVGRID;
637
+ assert_eq!(actual, expected);
638
+ }
639
+
640
+ #[test]
641
+ fn test_opencode_go_from_str() {
642
+ let actual = ProviderId::from_str("opencode_go").unwrap();
643
+ let expected = ProviderId::OPENCODE_GO;
644
+ assert_eq!(actual, expected);
645
+ }
646
+
647
+ #[test]
648
+ fn test_codex_in_built_in_providers() {
649
+ let built_in = ProviderId::built_in_providers();
650
+ assert!(built_in.contains(&ProviderId::CODEX));
651
+ assert!(built_in.contains(&ProviderId::OPENAI_RESPONSES_COMPATIBLE));
652
+ assert!(built_in.contains(&ProviderId::FIREWORKS_AI));
653
+ assert!(built_in.contains(&ProviderId::VIVGRID));
654
+ assert!(built_in.contains(&ProviderId::OPENCODE_GO));
655
+ assert!(built_in.contains(&ProviderId::GOOGLE_AI_STUDIO));
656
+ assert!(built_in.contains(&ProviderId::NVIDIA));
657
+ assert!(built_in.contains(&ProviderId::AMBIENT));
658
+ assert!(built_in.contains(&ProviderId::ORCA_ROUTER));
659
+ assert!(built_in.contains(&ProviderId::META));
660
+ }
661
+
662
+ #[test]
663
+ fn test_google_ai_studio_from_str() {
664
+ let actual = ProviderId::from_str("google_ai_studio").unwrap();
665
+ let expected = ProviderId::GOOGLE_AI_STUDIO;
666
+ assert_eq!(actual, expected);
667
+ }
668
+
669
+ #[test]
670
+ fn test_modal_from_str() {
671
+ let actual = ProviderId::from_str("modal").unwrap();
672
+ let expected = ProviderId::MODAL;
673
+ assert_eq!(actual, expected);
674
+ }
675
+
676
+ #[test]
677
+ fn test_adal_from_str() {
678
+ let actual = ProviderId::from_str("adal").unwrap();
679
+ let expected = ProviderId::ADAL;
680
+ assert_eq!(actual, expected);
681
+ }
682
+
683
+ #[test]
684
+ fn test_modal_display_name() {
685
+ assert_eq!(ProviderId::MODAL.to_string(), "Modal");
686
+ }
687
+
688
+ #[test]
689
+ fn test_modal_in_built_in_providers() {
690
+ let built_in = ProviderId::built_in_providers();
691
+ assert!(built_in.contains(&ProviderId::MODAL));
692
+ }
693
+
694
+ #[test]
695
+ fn test_adal_display_name() {
696
+ assert_eq!(ProviderId::ADAL.to_string(), "AdaL");
697
+ }
698
+
699
+ #[test]
700
+ fn test_adal_in_built_in_providers() {
701
+ let built_in = ProviderId::built_in_providers();
702
+ assert!(built_in.contains(&ProviderId::ADAL));
703
+ }
704
+
705
+ #[test]
706
+ fn test_xiaomi_mimo_from_str() {
707
+ let actual = ProviderId::from_str("xiaomi_mimo").unwrap();
708
+ let expected = ProviderId::XIAOMI_MIMO;
709
+ assert_eq!(actual, expected);
710
+ }
711
+
712
+ #[test]
713
+ fn test_xiaomi_mimo_display_name() {
714
+ assert_eq!(ProviderId::XIAOMI_MIMO.to_string(), "XiaomiMimo");
715
+ }
716
+
717
+ #[test]
718
+ fn test_xiaomi_mimo_in_built_in_providers() {
719
+ let built_in = ProviderId::built_in_providers();
720
+ assert!(built_in.contains(&ProviderId::XIAOMI_MIMO));
721
+ }
722
+
723
+ #[test]
724
+ fn test_ambient_from_str() {
725
+ let actual = ProviderId::from_str("ambient").unwrap();
726
+ let expected = ProviderId::AMBIENT;
727
+ assert_eq!(actual, expected);
728
+ }
729
+
730
+ #[test]
731
+ fn test_ambient_display_name() {
732
+ assert_eq!(ProviderId::AMBIENT.to_string(), "Ambient");
733
+ }
734
+
735
+ #[test]
736
+ fn test_ambient_in_built_in_providers() {
737
+ let built_in = ProviderId::built_in_providers();
738
+ assert!(built_in.contains(&ProviderId::AMBIENT));
739
+ }
740
+
741
+ #[test]
742
+ fn test_neuralwatt_from_str() {
743
+ let actual = ProviderId::from_str("neuralwatt").unwrap();
744
+ let expected = ProviderId::NEURALWATT;
745
+ assert_eq!(actual, expected);
746
+ }
747
+
748
+ #[test]
749
+ fn test_neuralwatt_display_name() {
750
+ assert_eq!(ProviderId::NEURALWATT.to_string(), "Neuralwatt");
751
+ }
752
+
753
+ #[test]
754
+ fn test_neuralwatt_in_built_in_providers() {
755
+ let built_in = ProviderId::built_in_providers();
756
+ assert!(built_in.contains(&ProviderId::NEURALWATT));
757
+ }
758
+
759
+ #[test]
760
+ fn test_orca_router_from_str() {
761
+ let actual = ProviderId::from_str("orca_router").unwrap();
762
+ let expected = ProviderId::ORCA_ROUTER;
763
+ assert_eq!(actual, expected);
764
+ }
765
+
766
+ #[test]
767
+ fn test_orca_router_display_name() {
768
+ assert_eq!(ProviderId::ORCA_ROUTER.to_string(), "OrcaRouter");
769
+ }
770
+
771
+ #[test]
772
+ fn test_orca_router_in_built_in_providers() {
773
+ let built_in = ProviderId::built_in_providers();
774
+ assert!(built_in.contains(&ProviderId::ORCA_ROUTER));
775
+ }
776
+
777
+ #[test]
778
+ fn test_meta_from_str() {
779
+ let actual = ProviderId::from_str("meta").unwrap();
780
+ let expected = ProviderId::META;
781
+ assert_eq!(actual, expected);
782
+ }
783
+
784
+ #[test]
785
+ fn test_meta_display_name() {
786
+ assert_eq!(ProviderId::META.to_string(), "Meta");
787
+ }
788
+
789
+ #[test]
790
+ fn test_meta_in_built_in_providers() {
791
+ let built_in = ProviderId::built_in_providers();
792
+ assert!(built_in.contains(&ProviderId::META));
793
+ }
794
+
795
+ #[test]
796
+ fn test_moonshot_display_name() {
797
+ let actual = ProviderId::MOONSHOT.to_string();
798
+ let expected = "Moonshot".to_string();
799
+ assert_eq!(actual, expected);
800
+ }
801
+
802
+ #[test]
803
+ fn test_moonshot_in_built_in_providers() {
804
+ let built_in = ProviderId::built_in_providers();
805
+ assert!(built_in.contains(&ProviderId::MOONSHOT));
806
+ }
807
+
808
+ #[test]
809
+ fn test_moonshot_from_str_roundtrip() {
810
+ let actual = ProviderId::from_str("moonshot").unwrap();
811
+ let expected = ProviderId::MOONSHOT;
812
+ assert_eq!(actual, expected);
813
+ }
814
+
815
+ #[test]
816
+ fn test_kimi_coding_from_str_roundtrip() {
817
+ let actual = ProviderId::from_str("kimi_coding").unwrap();
818
+ let expected = ProviderId::KIMI_CODING;
819
+ assert_eq!(actual, expected);
820
+ }
821
+
822
+ #[test]
823
+ fn test_alibaba_token_plan_display_name() {
824
+ let actual = ProviderId::ALIBABA_TOKEN_PLAN.to_string();
825
+ let expected = "AlibabaTokenPlan".to_string();
826
+ assert_eq!(actual, expected);
827
+ }
828
+
829
+ #[test]
830
+ fn test_alibaba_token_plan_in_built_in_providers() {
831
+ let built_in = ProviderId::built_in_providers();
832
+ assert!(built_in.contains(&ProviderId::ALIBABA_TOKEN_PLAN));
833
+ }
834
+
835
+ #[test]
836
+ fn test_alibaba_token_plan_from_str_roundtrip() {
837
+ let actual = ProviderId::from_str("alibaba_token_plan").unwrap();
838
+ let expected = ProviderId::ALIBABA_TOKEN_PLAN;
839
+ assert_eq!(actual, expected);
840
+ }
841
+
842
+ #[test]
843
+ fn test_io_intelligence() {
844
+ let fixture = "test_key";
845
+ let actual = io_intelligence(fixture);
846
+ let expected = Provider {
847
+ id: ProviderId::IO_INTELLIGENCE,
848
+ provider_type: Default::default(),
849
+ response: Some(ProviderResponse::OpenAI),
850
+ url: Url::from_str("https://api.intelligence.io.solutions/api/v1/chat/completions")
851
+ .unwrap(),
852
+ credential: Some(AuthCredential {
853
+ id: ProviderId::IO_INTELLIGENCE,
854
+ auth_details: AuthDetails::ApiKey(ApiKey::from(fixture.to_string())),
855
+ url_params: HashMap::new(),
856
+ }),
857
+ auth_methods: vec![crate::AuthMethod::ApiKey],
858
+ url_params: vec![],
859
+ models: Some(ModelSource::Url(
860
+ Url::from_str("https://api.intelligence.io.solutions/api/v1/models").unwrap(),
861
+ )),
862
+ custom_headers: None,
863
+ };
864
+ assert_eq!(actual, expected);
865
+ }
866
+
867
+ #[test]
868
+ fn test_xai() {
869
+ let fixture = "test_key";
870
+ let actual = xai(fixture);
871
+ let expected = Provider {
872
+ id: ProviderId::XAI,
873
+ provider_type: Default::default(),
874
+ response: Some(ProviderResponse::OpenAI),
875
+ url: Url::from_str("https://api.x.ai/v1/chat/completions").unwrap(),
876
+ credential: Some(AuthCredential {
877
+ id: ProviderId::XAI,
878
+ auth_details: AuthDetails::ApiKey(ApiKey::from(fixture.to_string())),
879
+ url_params: HashMap::new(),
880
+ }),
881
+ auth_methods: vec![crate::AuthMethod::ApiKey],
882
+ url_params: vec![],
883
+ models: Some(ModelSource::Url(
884
+ Url::from_str("https://api.x.ai/v1/models").unwrap(),
885
+ )),
886
+ custom_headers: None,
887
+ };
888
+ assert_eq!(actual, expected);
889
+ }
890
+
891
+ #[test]
892
+ fn test_is_xai_with_direct_comparison() {
893
+ let fixture_xai = xai("key");
894
+ assert_eq!(fixture_xai.id, ProviderId::XAI);
895
+
896
+ let fixture_other = openai("key");
897
+ assert_ne!(fixture_other.id, ProviderId::XAI);
898
+ }
899
+
900
+ #[test]
901
+ fn test_zai_coding_to_chat_url() {
902
+ let fixture = zai_coding("test_key");
903
+ let actual = fixture.url.clone();
904
+ let expected = Url::parse("https://api.z.ai/api/coding/paas/v4/chat/completions").unwrap();
905
+ assert_eq!(actual, expected);
906
+ }
907
+
908
+ #[test]
909
+ fn test_zai_coding_to_model_url() {
910
+ let fixture = zai_coding("test_key");
911
+ let actual = fixture.models.clone();
912
+ let expected = Some(ModelSource::Url(
913
+ Url::parse("https://api.z.ai/api/paas/v4/models").unwrap(),
914
+ ));
915
+ assert_eq!(actual, expected);
916
+ }
917
+
918
+ #[test]
919
+ fn test_regular_zai_to_chat_url() {
920
+ let fixture = zai("test_key");
921
+ let actual = fixture.url.clone();
922
+ let expected = Url::parse("https://api.z.ai/api/paas/v4/chat/completions").unwrap();
923
+ assert_eq!(actual, expected);
924
+ }
925
+
926
+ #[test]
927
+ fn test_regular_zai_to_model_url() {
928
+ let fixture = zai("test_key");
929
+ let actual = fixture.models.clone();
930
+ let expected = Some(ModelSource::Url(
931
+ Url::parse("https://api.z.ai/api/paas/v4/models").unwrap(),
932
+ ));
933
+ assert_eq!(actual, expected);
934
+ }
935
+
936
+ #[test]
937
+ fn test_vertex_ai_global_location() {
938
+ let fixture = vertex_ai("test_token", "forge-452914", "global");
939
+ let actual = fixture.url.clone();
940
+ let expected = Url::parse("https://aiplatform.googleapis.com/v1/projects/forge-452914/locations/global/endpoints/openapi/chat/completions").unwrap();
941
+ assert_eq!(actual, expected);
942
+ }
943
+
944
+ #[test]
945
+ fn test_vertex_ai_regular_location() {
946
+ let fixture = vertex_ai("test_token", "test_project", "us-central1");
947
+ let actual = fixture.url.clone();
948
+ let expected = Url::parse("https://us-central1-aiplatform.googleapis.com/v1/projects/test_project/locations/us-central1/endpoints/openapi/chat/completions").unwrap();
949
+ assert_eq!(actual, expected);
950
+ }
951
+
952
+ #[test]
953
+ fn test_fireworks_ai_firepass_from_str() {
954
+ let actual = ProviderId::from_str("fireworks-ai-firepass").unwrap();
955
+ let expected = ProviderId::FIREWORKS_AI_FIREPASS;
956
+ assert_eq!(actual, expected);
957
+ }
958
+
959
+ #[test]
960
+ fn test_fireworks_ai_firepass_display_name() {
961
+ assert_eq!(
962
+ ProviderId::FIREWORKS_AI_FIREPASS.to_string(),
963
+ "FireworksAIFirepass"
964
+ );
965
+ }
966
+
967
+ #[test]
968
+ fn test_fireworks_ai_firepass_in_built_in_providers() {
969
+ let built_in = ProviderId::built_in_providers();
970
+ assert!(built_in.contains(&ProviderId::FIREWORKS_AI_FIREPASS));
971
+ }
972
+
973
+ #[test]
974
+ fn test_azure_provider() {
975
+ let fixture = azure("test_key", "my-resource", "gpt-4", "2024-02-15-preview");
976
+
977
+ // Check chat completion URL (url field now contains the chat completion URL)
978
+ let actual_chat = fixture.url.clone();
979
+ let expected_chat = Url::parse("https://my-resource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-02-15-preview").unwrap();
980
+ assert_eq!(actual_chat, expected_chat);
981
+
982
+ // Check model URL
983
+ let actual_model = fixture.models.clone();
984
+ let expected_model = Some(ModelSource::Url(
985
+ Url::parse(
986
+ "https://my-resource.openai.azure.com/openai/models?api-version=2024-02-15-preview",
987
+ )
988
+ .unwrap(),
989
+ ));
990
+ assert_eq!(actual_model, expected_model);
991
+
992
+ assert_eq!(fixture.id, ProviderId::AZURE);
993
+ assert_eq!(fixture.response, Some(ProviderResponse::OpenAI));
994
+ }
995
+
996
+ #[test]
997
+ fn test_azure_provider_with_different_params() {
998
+ let fixture = azure("another_key", "east-us", "gpt-35-turbo", "2023-05-15");
999
+
1000
+ // Check chat completion URL
1001
+ let actual_chat = fixture.url.clone();
1002
+ let expected_chat = Url::parse("https://east-us.openai.azure.com/openai/deployments/gpt-35-turbo/chat/completions?api-version=2023-05-15").unwrap();
1003
+ assert_eq!(actual_chat, expected_chat);
1004
+
1005
+ // Check model URL
1006
+ let actual_model = fixture.models.clone();
1007
+ let expected_model = Some(ModelSource::Url(
1008
+ Url::parse("https://east-us.openai.azure.com/openai/models?api-version=2023-05-15")
1009
+ .unwrap(),
1010
+ ));
1011
+ assert_eq!(actual_model, expected_model);
1012
+ }
1013
+ }
crates/forge_domain/src/shell.rs ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Output from a command execution
2
+ #[derive(Debug, Clone)]
3
+ pub struct CommandOutput {
4
+ pub command: String,
5
+ pub stdout: String,
6
+ pub stderr: String,
7
+ pub exit_code: Option<i32>,
8
+ }
9
+
10
+ impl CommandOutput {
11
+ pub fn success(&self) -> bool {
12
+ self.exit_code.is_none_or(|code| code >= 0)
13
+ }
14
+ }
crates/forge_domain/src/skill.rs ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::path::PathBuf;
2
+
3
+ use derive_setters::Setters;
4
+ use schemars::JsonSchema;
5
+ use serde::{Deserialize, Serialize};
6
+
7
+ /// Represents a reusable skill with a name, file path, and prompt content
8
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Setters, JsonSchema)]
9
+ #[setters(strip_option, into)]
10
+ pub struct Skill {
11
+ /// Name of the skill
12
+ pub name: String,
13
+
14
+ /// File path to the skill markdown file
15
+ pub path: Option<PathBuf>,
16
+
17
+ /// Content/prompt loaded from the markdown file
18
+ pub command: String,
19
+
20
+ /// Description of the skill
21
+ pub description: String,
22
+
23
+ /// List of resource files in the skill directory
24
+ pub resources: Vec<PathBuf>,
25
+ }
26
+
27
+ impl Skill {
28
+ /// Creates a new Skill with required fields
29
+ ///
30
+ /// # Arguments
31
+ ///
32
+ /// * `name` - The name identifier for the skill
33
+ /// * `prompt` - The skill prompt content
34
+ /// * `description` - A brief description of the skill
35
+ pub fn new(
36
+ name: impl Into<String>,
37
+ prompt: impl Into<String>,
38
+ description: impl Into<String>,
39
+ ) -> Self {
40
+ Self {
41
+ name: name.into(),
42
+ path: None,
43
+ command: prompt.into(),
44
+ description: description.into(),
45
+ resources: Vec::new(),
46
+ }
47
+ }
48
+ }
49
+
50
+ #[cfg(test)]
51
+ mod tests {
52
+ use pretty_assertions::assert_eq;
53
+
54
+ use super::*;
55
+
56
+ #[test]
57
+ fn test_skill_creation() {
58
+ // Fixture
59
+ let fixture = Skill::new(
60
+ "code_review",
61
+ "Review this code",
62
+ "A skill for reviewing code quality",
63
+ )
64
+ .path("/skills/code_review.md");
65
+
66
+ // Act
67
+ let actual = (
68
+ fixture.name.clone(),
69
+ fixture.path.clone(),
70
+ fixture.command.clone(),
71
+ fixture.description.clone(),
72
+ );
73
+
74
+ // Assert
75
+ let expected = (
76
+ "code_review".to_string(),
77
+ Some("/skills/code_review.md".into()),
78
+ "Review this code".to_string(),
79
+ "A skill for reviewing code quality".to_string(),
80
+ );
81
+ assert_eq!(actual, expected);
82
+ }
83
+
84
+ #[test]
85
+ fn test_skill_with_setters() {
86
+ // Fixture
87
+ let fixture = Skill::new("test", "prompt", "desc")
88
+ .path("/path")
89
+ .name("updated_name")
90
+ .path("/updated/path")
91
+ .command("updated prompt")
92
+ .description("updated description");
93
+
94
+ // Act
95
+ let actual = fixture;
96
+
97
+ // Assert
98
+ let expected = Skill::new("updated_name", "updated prompt", "updated description")
99
+ .path("/updated/path");
100
+ assert_eq!(actual, expected);
101
+ }
102
+ }
crates/forge_domain/src/snapshot.rs ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::fmt::{Display, Formatter};
2
+ use std::hash::Hasher;
3
+ use std::path::PathBuf;
4
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
+
6
+ use serde::{Deserialize, Serialize};
7
+ use uuid::Uuid;
8
+
9
+ /// A newtype for snapshot IDs, internally using UUID
10
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
11
+ pub struct SnapshotId(Uuid);
12
+
13
+ impl SnapshotId {
14
+ /// Create a new random SnapshotId
15
+ pub fn new() -> Self {
16
+ Self(Uuid::new_v4())
17
+ }
18
+
19
+ /// Parse a SnapshotId from a string
20
+ pub fn parse(s: &str) -> Option<Self> {
21
+ Uuid::parse_str(s).ok().map(Self)
22
+ }
23
+
24
+ /// Get the underlying UUID
25
+ pub fn uuid(&self) -> &Uuid {
26
+ &self.0
27
+ }
28
+ }
29
+
30
+ impl Default for SnapshotId {
31
+ fn default() -> Self {
32
+ Self::new()
33
+ }
34
+ }
35
+
36
+ impl Display for SnapshotId {
37
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
38
+ write!(f, "{}", self.0)
39
+ }
40
+ }
41
+
42
+ impl From<Uuid> for SnapshotId {
43
+ fn from(uuid: Uuid) -> Self {
44
+ Self(uuid)
45
+ }
46
+ }
47
+
48
+ /// Represents information about a file snapshot
49
+ ///
50
+ /// Contains details about when the snapshot was created,
51
+ /// the original file path, the snapshot location, and file size.
52
+ #[derive(Debug, Clone, Serialize, Deserialize)]
53
+ pub struct Snapshot {
54
+ /// Unique ID for the file
55
+ pub id: SnapshotId,
56
+
57
+ /// Unix timestamp when the snapshot was created
58
+ pub timestamp: Duration,
59
+
60
+ /// Original file path that is being processed
61
+ pub path: String,
62
+ }
63
+
64
+ impl Snapshot {
65
+ pub fn create(path: PathBuf) -> anyhow::Result<Self> {
66
+ let path = match path.canonicalize() {
67
+ Ok(p) => p,
68
+ Err(_) => {
69
+ if path.is_absolute() {
70
+ path
71
+ } else {
72
+ anyhow::bail!(
73
+ "Path must be absolute. Please provide an absolute path starting with '/' (Unix) or 'C:\\' (Windows)"
74
+ );
75
+ }
76
+ }
77
+ };
78
+ let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?;
79
+
80
+ Ok(Self {
81
+ id: SnapshotId::new(),
82
+ timestamp,
83
+ path: path.display().to_string(),
84
+ })
85
+ }
86
+
87
+ /// Create a hash of a file path for storage
88
+ pub fn path_hash(&self) -> String {
89
+ let mut hasher = fnv_rs::Fnv64::default();
90
+ hasher.write(self.path.as_bytes());
91
+ format!("{:x}", hasher.finish())
92
+ }
93
+
94
+ /// Create a snapshot filename from a path and timestamp
95
+ pub fn snapshot_path(&self, cwd: Option<PathBuf>) -> PathBuf {
96
+ // Convert Duration to SystemTime then to a formatted string
97
+ let datetime = UNIX_EPOCH + self.timestamp;
98
+ // Format: YYYY-MM-DD_HH-MM-SS-nnnnnnnnn (including nanoseconds)
99
+ let formatted_time = chrono::DateTime::<chrono::Utc>::from(datetime)
100
+ .format("%Y-%m-%d_%H-%M-%S-%9f")
101
+ .to_string();
102
+
103
+ let filename = format!("{formatted_time}.snap");
104
+ let path = PathBuf::from(self.path_hash()).join(PathBuf::from(filename));
105
+ if let Some(cwd) = cwd {
106
+ cwd.join(path)
107
+ } else {
108
+ path
109
+ }
110
+ }
111
+ }
112
+
113
+ #[cfg(test)]
114
+ mod tests {
115
+ use super::*;
116
+
117
+ #[test]
118
+ fn test_create_with_nonexistent_absolute_path() {
119
+ // Test with a non-existent absolute path
120
+ let nonexistent_path = PathBuf::from("/this/path/does/not/exist/file.txt");
121
+ let snapshot = Snapshot::create(nonexistent_path.clone()).unwrap();
122
+
123
+ assert!(!snapshot.id.to_string().is_empty());
124
+ assert!(snapshot.timestamp.as_secs() > 0);
125
+ // Should use the original absolute path since canonicalize fails
126
+ assert_eq!(snapshot.path, nonexistent_path.display().to_string());
127
+ }
128
+
129
+ #[test]
130
+ fn test_create_with_nonexistent_relative_path() {
131
+ // Test with a non-existent relative path
132
+ let nonexistent_path = PathBuf::from("nonexistent/file.txt");
133
+ let snapshot = Snapshot::create(nonexistent_path.clone());
134
+ assert!(snapshot.is_err());
135
+ }
136
+
137
+ #[cfg(windows)]
138
+ #[test]
139
+ fn test_create_with_nonexistent_absolute_windows_path() {
140
+ // Test with Windows-style absolute path that doesn't exist
141
+ let nonexistent_path = PathBuf::from("C:\\nonexistent\\windows\\path\\file.txt");
142
+ let snapshot = Snapshot::create(nonexistent_path.clone()).unwrap();
143
+
144
+ assert!(!snapshot.id.to_string().is_empty());
145
+ assert!(snapshot.timestamp.as_secs() > 0);
146
+ assert_eq!(snapshot.path, nonexistent_path.display().to_string());
147
+ }
148
+ }
crates/forge_domain/src/suggestion.rs ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ use serde::{Deserialize, Serialize};
2
+
3
+ #[derive(Serialize, Deserialize)]
4
+ pub struct Suggestion {
5
+ pub use_case: String,
6
+ pub suggestion: String,
7
+ }
crates/forge_domain/src/temperature.rs ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::fmt;
2
+ use std::ops::Deref;
3
+
4
+ use schemars::JsonSchema;
5
+ use serde::{Deserialize, Deserializer, Serialize, Serializer};
6
+
7
+ /// A newtype for temperature values with built-in validation
8
+ ///
9
+ /// Temperature controls the randomness in the model's output:
10
+ /// - Lower values (e.g., 0.1) make responses more focused, deterministic, and
11
+ /// coherent
12
+ /// - Higher values (e.g., 0.8) make responses more creative, diverse, and
13
+ /// exploratory
14
+ /// - Valid range is 0.0 to 2.0
15
+ #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, JsonSchema)]
16
+ pub struct Temperature(f32);
17
+
18
+ impl Temperature {
19
+ /// Creates a new Temperature value, returning an error if outside the valid
20
+ /// range (0.0 to 2.0)
21
+ pub fn new(value: f32) -> Result<Self, String> {
22
+ if Self::is_valid(value) {
23
+ Ok(Self(value))
24
+ } else {
25
+ Err(format!(
26
+ "temperature must be between 0.0 and 2.0, got {value}"
27
+ ))
28
+ }
29
+ }
30
+
31
+ /// Creates a new Temperature value without validation
32
+ ///
33
+ /// # Safety
34
+ /// This function should only be used when the value is known to be valid
35
+ pub fn new_unchecked(value: f32) -> Self {
36
+ debug_assert!(Self::is_valid(value), "invalid temperature: {value}");
37
+ Self(value)
38
+ }
39
+
40
+ /// Returns true if the temperature value is within the valid range (0.0 to
41
+ /// 2.0)
42
+ pub fn is_valid(value: f32) -> bool {
43
+ (0.0..=2.0).contains(&value)
44
+ }
45
+
46
+ /// Returns the inner f32 value
47
+ pub fn value(&self) -> f32 {
48
+ self.0
49
+ }
50
+ }
51
+
52
+ impl Deref for Temperature {
53
+ type Target = f32;
54
+
55
+ fn deref(&self) -> &Self::Target {
56
+ &self.0
57
+ }
58
+ }
59
+
60
+ impl From<Temperature> for f32 {
61
+ fn from(temp: Temperature) -> Self {
62
+ temp.0
63
+ }
64
+ }
65
+
66
+ impl From<f32> for Temperature {
67
+ fn from(value: f32) -> Self {
68
+ Temperature::new_unchecked(value)
69
+ }
70
+ }
71
+
72
+ impl fmt::Display for Temperature {
73
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74
+ write!(f, "{}", self.0)
75
+ }
76
+ }
77
+
78
+ impl Serialize for Temperature {
79
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
80
+ where
81
+ S: Serializer,
82
+ {
83
+ // Convert to string with fixed precision to avoid floating point issues
84
+ // and then parse back to ensure consistent serialization
85
+ let formatted = format!("{:.1}", self.0);
86
+ let value = formatted.parse::<f32>().unwrap();
87
+ serializer.serialize_f32(value)
88
+ }
89
+ }
90
+
91
+ impl<'de> Deserialize<'de> for Temperature {
92
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
93
+ where
94
+ D: Deserializer<'de>,
95
+ {
96
+ use serde::de::Error;
97
+ let value = f32::deserialize(deserializer)?;
98
+ if Self::is_valid(value) {
99
+ Ok(Self(value))
100
+ } else {
101
+ Err(Error::custom(format!(
102
+ "temperature must be between 0.0 and 2.0, got {value}"
103
+ )))
104
+ }
105
+ }
106
+ }
107
+
108
+ #[cfg(test)]
109
+ mod tests {
110
+ use pretty_assertions::assert_eq;
111
+ use serde_json::json;
112
+
113
+ use super::*;
114
+
115
+ #[test]
116
+ fn test_temperature_creation() {
117
+ // Valid temperatures should be created successfully
118
+ let valid_temps = [0.0, 0.5, 1.0, 1.5, 2.0];
119
+ for temp in valid_temps {
120
+ let result = Temperature::new(temp);
121
+ assert!(result.is_ok(), "Temperature {temp} should be valid");
122
+ assert_eq!(result.unwrap().value(), temp);
123
+ }
124
+
125
+ // Invalid temperatures should return an error
126
+ let invalid_temps = [-0.1, 2.1, 3.0, -1.0, 10.0];
127
+ for temp in invalid_temps {
128
+ let result = Temperature::new(temp);
129
+ assert!(result.is_err(), "Temperature {temp} should be invalid");
130
+ assert!(
131
+ result
132
+ .unwrap_err()
133
+ .contains("temperature must be between 0.0 and 2.0"),
134
+ "Error should mention valid range"
135
+ );
136
+ }
137
+ }
138
+
139
+ #[test]
140
+ fn test_temperature_serialization() {
141
+ let temp = Temperature::new(0.7).unwrap();
142
+ let json = serde_json::to_value(temp).unwrap();
143
+
144
+ // When serializing floating point numbers, precision issues might occur
145
+ // So we'll check if the serialized value is approximately equal to 0.7
146
+ if let serde_json::Value::Number(num) = &json {
147
+ let float_val = num.as_f64().unwrap();
148
+ assert!(
149
+ (float_val - 0.7).abs() < 0.001,
150
+ "Expected approximately 0.7, got {float_val}"
151
+ );
152
+ } else {
153
+ panic!("Expected a number, got {json:?}");
154
+ }
155
+ }
156
+
157
+ #[test]
158
+ fn test_temperature_deserialization() {
159
+ // Valid temperature values should deserialize correctly
160
+ let valid_temps = [0.0, 0.5, 1.0, 1.5, 2.0];
161
+ for temp_value in valid_temps {
162
+ let json = json!(temp_value);
163
+ let temp: Result<Temperature, _> = serde_json::from_value(json);
164
+ assert!(
165
+ temp.is_ok(),
166
+ "Valid temperature {temp_value} should deserialize"
167
+ );
168
+ assert_eq!(temp.unwrap().value(), temp_value);
169
+ }
170
+
171
+ // Invalid temperature values should fail deserialization
172
+ let invalid_temps = [-0.1, 2.1, 3.0, -1.0, 10.0];
173
+ for temp_value in invalid_temps {
174
+ let json = json!(temp_value);
175
+ let temp: Result<Temperature, _> = serde_json::from_value(json);
176
+ assert!(
177
+ temp.is_err(),
178
+ "Invalid temperature {temp_value} should fail deserialization"
179
+ );
180
+ let err = temp.unwrap_err().to_string();
181
+ assert!(
182
+ err.contains("temperature must be between 0.0 and 2.0"),
183
+ "Error should mention valid range: {err}"
184
+ );
185
+ }
186
+ }
187
+
188
+ #[test]
189
+ fn test_temperature_in_struct() {
190
+ #[derive(Serialize, Deserialize, Debug)]
191
+ struct TestStruct {
192
+ temp: Temperature,
193
+ }
194
+
195
+ // Valid temperature
196
+ let json = json!({
197
+ "temp": 0.7
198
+ });
199
+ let test_struct: Result<TestStruct, _> = serde_json::from_value(json);
200
+ assert!(test_struct.is_ok());
201
+ assert_eq!(test_struct.unwrap().temp.value(), 0.7);
202
+
203
+ // Invalid temperature
204
+ let json = json!({
205
+ "temp": 2.5
206
+ });
207
+ let test_struct: Result<TestStruct, _> = serde_json::from_value(json);
208
+ assert!(test_struct.is_err());
209
+ let err = test_struct.unwrap_err().to_string();
210
+ assert!(
211
+ err.contains("temperature must be between 0.0 and 2.0"),
212
+ "Error should mention valid range: {err}"
213
+ );
214
+ }
215
+ }
crates/forge_domain/src/tool_order.rs ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::cmp::Ordering;
2
+ use std::collections::HashMap;
3
+ use std::sync::Arc;
4
+
5
+ use glob::Pattern;
6
+ use schemars::JsonSchema;
7
+ use serde::{Deserialize, Serialize};
8
+
9
+ use crate::{ToolDefinition, ToolName};
10
+
11
+ /// Defines the ordering for tools in an agent's context.
12
+ /// Tools are ordered based on weights - higher weight tools appear first.
13
+ /// When the list is empty, tools are sorted alphabetically.
14
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
15
+ pub struct ToolOrder {
16
+ /// The ordered list of tool names and patterns
17
+ tools: Vec<ToolName>,
18
+ /// Weight map: tool name -> weight (position index in the tools vector)
19
+ /// Pre-compiled for O(1) lookups during sorting
20
+ #[serde(skip)]
21
+ #[schemars(skip)]
22
+ weights: Arc<HashMap<ToolName, usize>>,
23
+ /// Pre-compiled glob patterns for matching with their positions
24
+ #[serde(skip)]
25
+ #[schemars(skip)]
26
+ patterns: Arc<Vec<(Pattern, usize)>>,
27
+ }
28
+
29
+ impl Default for ToolOrder {
30
+ fn default() -> Self {
31
+ Self {
32
+ tools: Vec::new(),
33
+ weights: Arc::new(HashMap::new()),
34
+ patterns: Arc::new(Vec::new()),
35
+ }
36
+ }
37
+ }
38
+
39
+ impl ToolOrder {
40
+ /// Creates a new ToolOrder with the specified tool names
41
+ ///
42
+ /// # Arguments
43
+ ///
44
+ /// * `tools` - List of tool names (and patterns) to use as the basis for
45
+ /// ordering
46
+ pub fn new(tools: Vec<ToolName>) -> Self {
47
+ let mut weights = HashMap::new();
48
+ let mut patterns = Vec::new();
49
+
50
+ for (index, tool_name) in tools.iter().enumerate() {
51
+ // Try to compile as a glob pattern
52
+ if let Ok(pattern) = Pattern::new(tool_name.as_str()) {
53
+ // Check if it's actually a pattern (contains wildcards)
54
+ if tool_name.as_str().contains('*') || tool_name.as_str().contains('?') {
55
+ patterns.push((pattern, index));
56
+ continue;
57
+ }
58
+ }
59
+
60
+ // Not a pattern, store as exact match
61
+ weights.insert(tool_name.clone(), index);
62
+ }
63
+
64
+ Self {
65
+ tools,
66
+ weights: Arc::new(weights),
67
+ patterns: Arc::new(patterns),
68
+ }
69
+ }
70
+
71
+ /// Creates a ToolOrder from a list of tool names, using the exact order
72
+ /// as specified in the list, including glob patterns.
73
+ ///
74
+ /// # Arguments
75
+ ///
76
+ /// * `tools` - List of tool names (and patterns) to use as the basis for
77
+ /// ordering
78
+ pub fn from_tool_list(tools: &[ToolName]) -> Self {
79
+ if tools.is_empty() {
80
+ return Self::default();
81
+ }
82
+
83
+ Self::new(tools.to_vec())
84
+ }
85
+
86
+ /// Sorts tool definitions according to the ordering strategy
87
+ ///
88
+ /// # Arguments
89
+ ///
90
+ /// * `tools` - Mutable slice of tool definitions to sort
91
+ pub fn sort(&self, tools: &mut [ToolDefinition]) {
92
+ if self.tools.is_empty() {
93
+ // Empty order means alphabetical
94
+ tools.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
95
+ } else {
96
+ tools.sort_by(|a, b| self.compare_by_weight(&a.name, &b.name));
97
+ }
98
+ }
99
+
100
+ /// Sorts tool definition references according to the ordering strategy
101
+ ///
102
+ /// # Arguments
103
+ ///
104
+ /// * `tools` - Mutable slice of tool definition references to sort
105
+ pub fn sort_refs(&self, tools: &mut [&ToolDefinition]) {
106
+ if self.tools.is_empty() {
107
+ // Empty order means alphabetical
108
+ tools.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
109
+ } else {
110
+ tools.sort_by(|a, b| self.compare_by_weight(&a.name, &b.name));
111
+ }
112
+ }
113
+
114
+ /// Gets the weight (position) of a tool by checking exact matches first,
115
+ /// then patterns
116
+ ///
117
+ /// Returns None if the tool doesn't match any entry in the order list.
118
+ fn get_weight(&self, tool: &ToolName) -> Option<usize> {
119
+ // First check exact match in weights map - O(1)
120
+ if let Some(&weight) = self.weights.get(tool) {
121
+ return Some(weight);
122
+ }
123
+
124
+ // Then check glob patterns - O(p) where p is number of patterns
125
+ for (pattern, weight) in self.patterns.iter() {
126
+ if pattern.matches(tool.as_str()) {
127
+ return Some(*weight);
128
+ }
129
+ }
130
+
131
+ None
132
+ }
133
+
134
+ /// Compares two tool names by their weights
135
+ ///
136
+ /// Tools with lower position indices come first. Tools with no weight are
137
+ /// sorted alphabetically and appear after weighted tools.
138
+ fn compare_by_weight(&self, a: &ToolName, b: &ToolName) -> Ordering {
139
+ let a_weight = self.get_weight(a);
140
+ let b_weight = self.get_weight(b);
141
+
142
+ match (a_weight, b_weight) {
143
+ // Both have weights - lower weight (earlier position) comes first
144
+ (Some(w_a), Some(w_b)) => match w_a.cmp(&w_b) {
145
+ // If weights are equal (e.g., both match same pattern), sort
146
+ // alphabetically
147
+ Ordering::Equal => a.as_str().cmp(b.as_str()),
148
+ other => other,
149
+ },
150
+ // Only 'a' has weight, so it comes first
151
+ (Some(_), None) => Ordering::Less,
152
+ // Only 'b' has weight, so it comes first
153
+ (None, Some(_)) => Ordering::Greater,
154
+ // Neither has weight, sort alphabetically
155
+ (None, None) => a.as_str().cmp(b.as_str()),
156
+ }
157
+ }
158
+ }
159
+
160
+ #[cfg(test)]
161
+ mod tests {
162
+ use pretty_assertions::assert_eq;
163
+
164
+ use super::*;
165
+
166
+ #[test]
167
+ fn test_alphabetical_sort() {
168
+ let fixture_order = ToolOrder::new(vec![]); // Empty list = alphabetical
169
+ let mut fixture = vec![
170
+ ToolDefinition::new("zebra").description("Z tool"),
171
+ ToolDefinition::new("alpha").description("A tool"),
172
+ ToolDefinition::new("beta").description("B tool"),
173
+ ];
174
+
175
+ fixture_order.sort(&mut fixture);
176
+
177
+ let actual: Vec<String> = fixture.iter().map(|t| t.name.to_string()).collect();
178
+ let expected = vec!["alpha", "beta", "zebra"];
179
+
180
+ assert_eq!(actual, expected);
181
+ }
182
+
183
+ #[test]
184
+ fn test_custom_order_all_specified() {
185
+ let fixture_order = ToolOrder::new(vec![
186
+ ToolName::new("beta"),
187
+ ToolName::new("alpha"),
188
+ ToolName::new("zebra"),
189
+ ]);
190
+ let mut fixture = vec![
191
+ ToolDefinition::new("zebra").description("Z tool"),
192
+ ToolDefinition::new("alpha").description("A tool"),
193
+ ToolDefinition::new("beta").description("B tool"),
194
+ ];
195
+
196
+ fixture_order.sort(&mut fixture);
197
+
198
+ let actual: Vec<String> = fixture.iter().map(|t| t.name.to_string()).collect();
199
+ let expected = vec!["beta", "alpha", "zebra"];
200
+
201
+ assert_eq!(actual, expected);
202
+ }
203
+
204
+ #[test]
205
+ fn test_custom_order_partial_specification() {
206
+ let fixture_order = ToolOrder::new(vec![ToolName::new("zebra"), ToolName::new("beta")]);
207
+ let mut fixture = vec![
208
+ ToolDefinition::new("alpha").description("A tool"),
209
+ ToolDefinition::new("beta").description("B tool"),
210
+ ToolDefinition::new("zebra").description("Z tool"),
211
+ ToolDefinition::new("delta").description("D tool"),
212
+ ToolDefinition::new("charlie").description("C tool"),
213
+ ];
214
+
215
+ fixture_order.sort(&mut fixture);
216
+
217
+ let actual: Vec<String> = fixture.iter().map(|t| t.name.to_string()).collect();
218
+ // zebra and beta come first (in that order), rest alphabetically
219
+ let expected = vec!["zebra", "beta", "alpha", "charlie", "delta"];
220
+
221
+ assert_eq!(actual, expected);
222
+ }
223
+
224
+ #[test]
225
+ fn test_custom_order_with_refs() {
226
+ let fixture_order = ToolOrder::new(vec![ToolName::new("write"), ToolName::new("read")]);
227
+ let tools = [
228
+ ToolDefinition::new("read").description("Read tool"),
229
+ ToolDefinition::new("write").description("Write tool"),
230
+ ToolDefinition::new("patch").description("Patch tool"),
231
+ ];
232
+ let mut fixture: Vec<&ToolDefinition> = tools.iter().collect();
233
+
234
+ fixture_order.sort_refs(&mut fixture);
235
+
236
+ let actual: Vec<String> = fixture.iter().map(|t| t.name.to_string()).collect();
237
+ let expected = vec!["write", "read", "patch"];
238
+
239
+ assert_eq!(actual, expected);
240
+ }
241
+
242
+ #[test]
243
+ fn test_custom_order_empty_list() {
244
+ let fixture_order = ToolOrder::new(vec![]);
245
+ let mut fixture = vec![
246
+ ToolDefinition::new("zebra").description("Z tool"),
247
+ ToolDefinition::new("alpha").description("A tool"),
248
+ ToolDefinition::new("beta").description("B tool"),
249
+ ];
250
+
251
+ fixture_order.sort(&mut fixture);
252
+
253
+ let actual: Vec<String> = fixture.iter().map(|t| t.name.to_string()).collect();
254
+ // Should fall back to alphabetical
255
+ let expected = vec!["alpha", "beta", "zebra"];
256
+
257
+ assert_eq!(actual, expected);
258
+ }
259
+
260
+ #[test]
261
+ fn test_from_tool_list_exact_order() {
262
+ let fixture = vec![
263
+ ToolName::new("write"),
264
+ ToolName::new("read"),
265
+ ToolName::new("sage"),
266
+ ToolName::new("patch"),
267
+ ToolName::new("sem_search"),
268
+ ];
269
+
270
+ let actual = ToolOrder::from_tool_list(&fixture);
271
+
272
+ let names: Vec<String> = actual.tools.iter().map(|t| t.to_string()).collect();
273
+ // Should maintain exact order as specified
274
+ assert_eq!(names[0], "write");
275
+ assert_eq!(names[1], "read");
276
+ assert_eq!(names[2], "sage");
277
+ assert_eq!(names[3], "patch");
278
+ assert_eq!(names[4], "sem_search");
279
+ }
280
+
281
+ #[test]
282
+ fn test_from_tool_list_with_mcp_tools() {
283
+ let fixture = vec![
284
+ ToolName::new("read"),
285
+ ToolName::new("mcp_github"),
286
+ ToolName::new("write"),
287
+ ToolName::new("mcp_slack"),
288
+ ToolName::new("patch"),
289
+ ];
290
+
291
+ let actual = ToolOrder::from_tool_list(&fixture);
292
+
293
+ let names: Vec<String> = actual.tools.iter().map(|t| t.to_string()).collect();
294
+ // Should maintain exact order as specified, no special rules
295
+ assert_eq!(names[0], "read");
296
+ assert_eq!(names[1], "mcp_github");
297
+ assert_eq!(names[2], "write");
298
+ assert_eq!(names[3], "mcp_slack");
299
+ assert_eq!(names[4], "patch");
300
+ }
301
+
302
+ #[test]
303
+ fn test_from_tool_list_empty() {
304
+ let fixture: Vec<ToolName> = vec![];
305
+
306
+ let actual = ToolOrder::from_tool_list(&fixture);
307
+
308
+ assert_eq!(actual, ToolOrder::new(vec![]));
309
+ }
310
+
311
+ #[test]
312
+ fn test_from_tool_list_with_glob_patterns() {
313
+ let fixture = vec![
314
+ ToolName::new("read"),
315
+ ToolName::new("fs_*"), // Glob pattern - preserved
316
+ ToolName::new("write"),
317
+ ToolName::new("mcp_*"), // Glob pattern - preserved
318
+ ToolName::new("patch"),
319
+ ];
320
+
321
+ let actual = ToolOrder::from_tool_list(&fixture);
322
+
323
+ let names: Vec<String> = actual.tools.iter().map(|t| t.to_string()).collect();
324
+ // All tools and patterns preserved
325
+ assert_eq!(names.len(), 5);
326
+ assert_eq!(names[0], "read");
327
+ assert_eq!(names[1], "fs_*");
328
+ assert_eq!(names[2], "write");
329
+ assert_eq!(names[3], "mcp_*");
330
+ assert_eq!(names[4], "patch");
331
+ }
332
+
333
+ #[test]
334
+ fn test_custom_order_with_glob_pattern_matching() {
335
+ let fixture_order = ToolOrder::new(vec![
336
+ ToolName::new("read"),
337
+ ToolName::new("fs_*"),
338
+ ToolName::new("shell"),
339
+ ]);
340
+ let mut fixture = vec![
341
+ ToolDefinition::new("shell").description("Shell tool"),
342
+ ToolDefinition::new("fs_write").description("FS Write"),
343
+ ToolDefinition::new("read").description("Read tool"),
344
+ ToolDefinition::new("fs_read").description("FS Read"),
345
+ ];
346
+
347
+ fixture_order.sort(&mut fixture);
348
+
349
+ let actual: Vec<String> = fixture.iter().map(|t| t.name.to_string()).collect();
350
+ // read (pos 0), fs_read and fs_write (both match fs_* at pos 1, alphabetically
351
+ // sorted), shell (pos 2)
352
+ let expected = vec!["read", "fs_read", "fs_write", "shell"];
353
+
354
+ assert_eq!(actual, expected);
355
+ }
356
+
357
+ #[test]
358
+ fn test_is_empty() {
359
+ let empty = ToolOrder::new(vec![]);
360
+ let non_empty = ToolOrder::new(vec![ToolName::new("read")]);
361
+
362
+ assert!(empty.tools.is_empty());
363
+ assert!(!non_empty.tools.is_empty());
364
+ }
365
+
366
+ #[test]
367
+ fn test_weight_lookup_optimization() {
368
+ // Test that the HashMap optimization works correctly
369
+ let fixture_order = ToolOrder::new(vec![
370
+ ToolName::new("alpha"),
371
+ ToolName::new("beta"),
372
+ ToolName::new("gamma"),
373
+ ]);
374
+
375
+ // Verify weights are stored correctly
376
+ assert_eq!(fixture_order.get_weight(&ToolName::new("alpha")), Some(0));
377
+ assert_eq!(fixture_order.get_weight(&ToolName::new("beta")), Some(1));
378
+ assert_eq!(fixture_order.get_weight(&ToolName::new("gamma")), Some(2));
379
+ assert_eq!(fixture_order.get_weight(&ToolName::new("delta")), None);
380
+ }
381
+
382
+ #[test]
383
+ fn test_pattern_matching_optimization() {
384
+ // Test that patterns are pre-compiled
385
+ let fixture_order = ToolOrder::new(vec![
386
+ ToolName::new("exact_match"),
387
+ ToolName::new("prefix_*"),
388
+ ToolName::new("*_suffix"),
389
+ ]);
390
+
391
+ // Exact matches should be in weights
392
+ assert_eq!(
393
+ fixture_order.get_weight(&ToolName::new("exact_match")),
394
+ Some(0)
395
+ );
396
+
397
+ // Pattern matches should work
398
+ assert_eq!(
399
+ fixture_order.get_weight(&ToolName::new("prefix_test")),
400
+ Some(1)
401
+ );
402
+ assert_eq!(
403
+ fixture_order.get_weight(&ToolName::new("prefix_foo")),
404
+ Some(1)
405
+ );
406
+ assert_eq!(
407
+ fixture_order.get_weight(&ToolName::new("test_suffix")),
408
+ Some(2)
409
+ );
410
+
411
+ // Non-matches should return None
412
+ assert_eq!(fixture_order.get_weight(&ToolName::new("no_match")), None);
413
+ }
414
+
415
+ #[test]
416
+ fn test_clone_is_cheap() {
417
+ // Test that cloning ToolOrder is cheap (Arc is cloned, not the data)
418
+ let fixture_order = ToolOrder::new(vec![
419
+ ToolName::new("alpha"),
420
+ ToolName::new("beta"),
421
+ ToolName::new("gamma"),
422
+ ]);
423
+
424
+ let cloned = fixture_order.clone();
425
+
426
+ // Both should have the same Arc pointers (cheap clone)
427
+ assert_eq!(
428
+ Arc::as_ptr(&fixture_order.weights),
429
+ Arc::as_ptr(&cloned.weights)
430
+ );
431
+ assert_eq!(
432
+ Arc::as_ptr(&fixture_order.patterns),
433
+ Arc::as_ptr(&cloned.patterns)
434
+ );
435
+ }
436
+ }
crates/forge_domain/src/top_k.rs ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::fmt;
2
+ use std::ops::Deref;
3
+
4
+ use schemars::JsonSchema;
5
+ use serde::{Deserialize, Deserializer, Serialize, Serializer};
6
+
7
+ /// A newtype for top_k values with built-in validation
8
+ ///
9
+ /// Top-k controls the number of highest probability vocabulary tokens to keep:
10
+ /// - Lower values (e.g., 10) make responses more focused by considering only
11
+ /// the top K most likely tokens
12
+ /// - Higher values (e.g., 100) make responses more diverse by considering more
13
+ /// token options
14
+ /// - Valid range is 1 to 1000 (inclusive)
15
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
16
+ pub struct TopK(u32);
17
+
18
+ impl TopK {
19
+ /// Creates a new TopK value, returning an error if outside the valid
20
+ /// range (1 to 1000)
21
+ pub fn new(value: u32) -> Result<Self, String> {
22
+ if Self::is_valid(value) {
23
+ Ok(Self(value))
24
+ } else {
25
+ Err(format!("top_k must be between 1 and 1000, got {value}"))
26
+ }
27
+ }
28
+
29
+ /// Creates a new TopK value without validation
30
+ ///
31
+ /// # Safety
32
+ /// This function should only be used when the value is known to be valid
33
+ pub fn new_unchecked(value: u32) -> Self {
34
+ debug_assert!(Self::is_valid(value), "invalid top_k: {value}");
35
+ Self(value)
36
+ }
37
+
38
+ /// Returns true if the top_k value is within the valid range (1 to 1000)
39
+ pub fn is_valid(value: u32) -> bool {
40
+ (1..=1000).contains(&value)
41
+ }
42
+
43
+ /// Returns the inner u32 value
44
+ pub fn value(&self) -> u32 {
45
+ self.0
46
+ }
47
+ }
48
+
49
+ impl Deref for TopK {
50
+ type Target = u32;
51
+
52
+ fn deref(&self) -> &Self::Target {
53
+ &self.0
54
+ }
55
+ }
56
+
57
+ impl From<TopK> for u32 {
58
+ fn from(top_k: TopK) -> Self {
59
+ top_k.0
60
+ }
61
+ }
62
+
63
+ impl fmt::Display for TopK {
64
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65
+ write!(f, "{}", self.0)
66
+ }
67
+ }
68
+
69
+ impl Serialize for TopK {
70
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
71
+ where
72
+ S: Serializer,
73
+ {
74
+ serializer.serialize_u32(self.0)
75
+ }
76
+ }
77
+
78
+ impl<'de> Deserialize<'de> for TopK {
79
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
80
+ where
81
+ D: Deserializer<'de>,
82
+ {
83
+ use serde::de::Error;
84
+ let value = u32::deserialize(deserializer)?;
85
+ if Self::is_valid(value) {
86
+ Ok(Self(value))
87
+ } else {
88
+ Err(Error::custom(format!(
89
+ "top_k must be between 1 and 1000, got {value}"
90
+ )))
91
+ }
92
+ }
93
+ }
94
+
95
+ #[cfg(test)]
96
+ mod tests {
97
+ use pretty_assertions::assert_eq;
98
+ use serde_json::json;
99
+
100
+ use super::*;
101
+
102
+ #[test]
103
+ fn test_top_k_creation() {
104
+ // Valid top_k values should be created successfully
105
+ let valid_values = [1, 10, 50, 100, 500, 1000];
106
+ for value in valid_values {
107
+ let result = TopK::new(value);
108
+ assert!(result.is_ok(), "TopK {value} should be valid");
109
+ assert_eq!(result.unwrap().value(), value);
110
+ }
111
+
112
+ // Invalid top_k values should return an error
113
+ let invalid_values = [0, 1001, 2000, 5000];
114
+ for value in invalid_values {
115
+ let result = TopK::new(value);
116
+ assert!(result.is_err(), "TopK {value} should be invalid");
117
+ assert!(
118
+ result
119
+ .unwrap_err()
120
+ .contains("top_k must be between 1 and 1000"),
121
+ "Error should mention valid range"
122
+ );
123
+ }
124
+ }
125
+
126
+ #[test]
127
+ fn test_top_k_serialization() {
128
+ let top_k = TopK::new(50).unwrap();
129
+ let json = serde_json::to_value(top_k).unwrap();
130
+
131
+ if let serde_json::Value::Number(num) = &json {
132
+ let int_val = num.as_u64().unwrap();
133
+ assert_eq!(int_val, 50);
134
+ } else {
135
+ panic!("Expected a number, got {json:?}");
136
+ }
137
+ }
138
+
139
+ #[test]
140
+ fn test_top_k_deserialization() {
141
+ // Valid top_k values should deserialize correctly
142
+ let valid_values = [1, 10, 50, 100, 500, 1000];
143
+ for value in valid_values {
144
+ let json = json!(value);
145
+ let top_k: Result<TopK, _> = serde_json::from_value(json);
146
+ assert!(top_k.is_ok(), "Valid top_k {value} should deserialize");
147
+ assert_eq!(top_k.unwrap().value(), value);
148
+ }
149
+
150
+ // Invalid top_k values should fail deserialization
151
+ let invalid_values = [0, 1001, 2000, 5000];
152
+ for value in invalid_values {
153
+ let json = json!(value);
154
+ let top_k: Result<TopK, _> = serde_json::from_value(json);
155
+ assert!(
156
+ top_k.is_err(),
157
+ "Invalid top_k {value} should fail deserialization"
158
+ );
159
+ let err = top_k.unwrap_err().to_string();
160
+ assert!(
161
+ err.contains("top_k must be between 1 and 1000"),
162
+ "Error should mention valid range: {err}"
163
+ );
164
+ }
165
+ }
166
+
167
+ #[test]
168
+ fn test_top_k_in_struct() {
169
+ #[derive(Serialize, Deserialize, Debug)]
170
+ struct TestStruct {
171
+ top_k: TopK,
172
+ }
173
+
174
+ // Valid top_k
175
+ let json = json!({
176
+ "top_k": 50
177
+ });
178
+ let test_struct: Result<TestStruct, _> = serde_json::from_value(json);
179
+ assert!(test_struct.is_ok());
180
+ assert_eq!(test_struct.unwrap().top_k.value(), 50);
181
+
182
+ // Invalid top_k
183
+ let json = json!({
184
+ "top_k": 1500
185
+ });
186
+ let test_struct: Result<TestStruct, _> = serde_json::from_value(json);
187
+ assert!(test_struct.is_err());
188
+ let err = test_struct.unwrap_err().to_string();
189
+ assert!(
190
+ err.contains("top_k must be between 1 and 1000"),
191
+ "Error should mention valid range: {err}"
192
+ );
193
+ }
194
+ }
crates/forge_domain/src/update.rs ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::time::Duration;
2
+
3
+ use derive_setters::Setters;
4
+ use merge::Merge;
5
+ use schemars::JsonSchema;
6
+ use serde::{Deserialize, Serialize};
7
+
8
+ #[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
9
+ #[serde(rename_all = "snake_case")]
10
+ pub enum UpdateFrequency {
11
+ Daily,
12
+ Weekly,
13
+ Never,
14
+ #[default]
15
+ Always,
16
+ }
17
+
18
+ impl From<UpdateFrequency> for Duration {
19
+ fn from(val: UpdateFrequency) -> Self {
20
+ match val {
21
+ UpdateFrequency::Daily => Duration::from_secs(60 * 60 * 24), // 1 day
22
+ UpdateFrequency::Weekly => Duration::from_secs(60 * 60 * 24 * 7), // 1 week
23
+ UpdateFrequency::Never => Duration::MAX,
24
+ UpdateFrequency::Always => Duration::ZERO, // one time,
25
+ }
26
+ }
27
+ }
28
+
29
+ #[derive(Debug, Clone, Serialize, Deserialize, Merge, Default, JsonSchema, Setters, PartialEq)]
30
+ #[merge(strategy = merge::option::overwrite_none)]
31
+ pub struct Update {
32
+ pub frequency: Option<UpdateFrequency>,
33
+ pub auto_update: Option<bool>,
34
+ }
crates/forge_domain/src/validation.rs ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Represents a single syntax error in a file
2
+ #[derive(Debug, Clone, PartialEq, Eq)]
3
+ pub struct SyntaxError {
4
+ /// Line number where the error occurred (1-based)
5
+ pub line: u32,
6
+ /// Column number where the error occurred (1-based)
7
+ pub column: u32,
8
+ /// Error message describing the syntax issue
9
+ pub message: String,
10
+ }
crates/forge_domain/src/workspace.rs ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use derive_more::Display;
2
+ use serde::{Deserialize, Serialize};
3
+ use uuid::Uuid;
4
+
5
+ /// Workspace identifier (UUID) from workspace server.
6
+ ///
7
+ /// Generated locally and sent to server during CreateWorkspace.
8
+ #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display)]
9
+ #[display("{}", _0)]
10
+ pub struct WorkspaceId(Uuid);
11
+
12
+ impl WorkspaceId {
13
+ /// Generate a new random workspace ID
14
+ pub fn generate() -> Self {
15
+ Self(Uuid::new_v4())
16
+ }
17
+
18
+ /// Parse a workspace ID from a string
19
+ ///
20
+ /// # Errors
21
+ /// Returns an error if the string is not a valid UUID
22
+ pub fn from_string(s: &str) -> anyhow::Result<Self> {
23
+ Ok(Self(Uuid::parse_str(s)?))
24
+ }
25
+
26
+ /// Get the inner UUID
27
+ pub fn inner(&self) -> Uuid {
28
+ self.0
29
+ }
30
+ }
crates/forge_domain/src/xml.rs ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Extracts content between the specified XML-style tags
2
+ ///
3
+ /// # Arguments
4
+ ///
5
+ /// * `text` - The text to extract content from
6
+ /// * `tag_name` - The name of the XML tag (without angle brackets)
7
+ ///
8
+ /// # Returns
9
+ ///
10
+ /// * `Some(&str)` containing the extracted content if tags are found
11
+ /// * `None` if the tags are not found
12
+ pub fn extract_tag_content<'a>(text: &'a str, tag_name: &str) -> Option<&'a str> {
13
+ let opening_tag = format!("<{tag_name}>",);
14
+ let closing_tag = format!("</{tag_name}>");
15
+
16
+ #[allow(clippy::collapsible_if)]
17
+ if let Some(start_idx) = text.find(&opening_tag) {
18
+ if let Some(end_idx) = text.rfind(&closing_tag) {
19
+ let content_start = start_idx + opening_tag.len();
20
+ if content_start < end_idx {
21
+ return text.get(content_start..end_idx).map(|s| s.trim());
22
+ }
23
+ }
24
+ }
25
+
26
+ None
27
+ }
28
+
29
+ /// Removes content within XML-style tags that start with the specified prefix
30
+ pub fn remove_tag_with_prefix(text: &str, prefix: &str) -> String {
31
+ // First, find all unique tag names that start with the prefix
32
+ let tag_pattern = format!(r"<({prefix}[a-zA-Z0-9_-]*?)(?:\s[^>]*?)?>");
33
+ let mut tag_names = Vec::new();
34
+
35
+ if let Ok(regex) = regex::Regex::new(&tag_pattern) {
36
+ for captures in regex.captures_iter(text) {
37
+ if let Some(tag_name) = captures.get(1) {
38
+ // Only add unique tag names to the list
39
+ let tag_name = tag_name.as_str().to_string();
40
+ if !tag_names.contains(&tag_name) {
41
+ tag_names.push(tag_name);
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ // Now remove content for each tag name found
48
+ let mut result = text.to_string();
49
+ for tag_name in tag_names {
50
+ // Create pattern to match complete tag including content
51
+ let pattern = format!(r"<{tag_name}(?:\s[^>]*?)?>[\s\S]*?</{tag_name}>");
52
+
53
+ if let Ok(regex) = regex::Regex::new(&pattern) {
54
+ result = regex.replace_all(&result, "").to_string();
55
+ }
56
+ }
57
+
58
+ result
59
+ }
60
+
61
+ #[cfg(test)]
62
+ mod tests {
63
+ use pretty_assertions::assert_eq;
64
+
65
+ use super::*;
66
+
67
+ #[test]
68
+ fn test_extract_tag_content() {
69
+ let fixture = "Some text <summary>This is the important part</summary> and more text";
70
+ let actual = extract_tag_content(fixture, "summary");
71
+ let expected = Some("This is the important part");
72
+ assert_eq!(actual, expected);
73
+ }
74
+
75
+ #[test]
76
+ fn test_extract_tag_content_no_tags() {
77
+ let fixture = "Some text without any tags";
78
+ let actual = extract_tag_content(fixture, "summary");
79
+ let expected = None;
80
+ assert_eq!(actual, expected);
81
+ }
82
+
83
+ #[test]
84
+ fn test_extract_tag_content_with_different_tag() {
85
+ let fixture = "Text with <custom>Custom content</custom> tags";
86
+ let actual = extract_tag_content(fixture, "custom");
87
+ let expected = Some("Custom content");
88
+ assert_eq!(actual, expected);
89
+ }
90
+
91
+ #[test]
92
+ fn test_extract_tag_content_with_malformed_tags() {
93
+ let fixture = "Text with <opening> but no closing tag";
94
+ let actual = extract_tag_content(fixture, "opening");
95
+ let expected = None;
96
+ assert_eq!(actual, expected);
97
+ }
98
+
99
+ #[test]
100
+ fn test_extract_tag_names_with_prefix() {
101
+ let fixture = "<forge_tool>Something</forge_tool> <forge_tool_call>Content</forge_tool_call> <other>More</other>";
102
+ let actual = remove_tag_with_prefix(fixture, "forge");
103
+ // Check that both tool tags have been removed, leaving only <other> tag
104
+ assert!(actual.contains("<other>More</other>"));
105
+ assert!(!actual.contains("<forge_tool>"));
106
+ assert!(!actual.contains("<forge_tool_call>"));
107
+ }
108
+
109
+ #[test]
110
+ fn test_extract_tag_names_with_prefix_no_matches() {
111
+ let fixture = "<other>Some content</other> <another>Other content</another>";
112
+ let actual = remove_tag_with_prefix(fixture, "forge");
113
+ let expected = "<other>Some content</other> <another>Other content</another>";
114
+ assert_eq!(actual, expected);
115
+ }
116
+
117
+ #[test]
118
+ fn test_extract_tag_names_with_prefix_nested() {
119
+ let fixture = "<parent><forge_tool>Inner</forge_tool><forge_tool_call>Nested</forge_tool_call></parent>";
120
+ let actual = remove_tag_with_prefix(fixture, "forge");
121
+ let expected = "<parent></parent>";
122
+ assert_eq!(actual, expected);
123
+ }
124
+
125
+ #[test]
126
+ fn test_extract_tag_names_with_prefix_duplicates() {
127
+ let fixture =
128
+ "<forge_tool>First</forge_tool><other>Middle</other><forge_tool>Second</forge_tool>";
129
+ let actual = remove_tag_with_prefix(fixture, "forge");
130
+ let expected = "<other>Middle</other>";
131
+ assert_eq!(actual, expected);
132
+ }
133
+
134
+ #[test]
135
+ fn test_extract_tag_names_with_prefix_attributes() {
136
+ let fixture = "<forge_tool id=\"1\">Content</forge_tool> <forge_tool_call class=\"important\">More</forge_tool_call>";
137
+ let actual = remove_tag_with_prefix(fixture, "forge");
138
+ // Check that both tool tags have been removed
139
+ assert!(!actual.contains("<forge_tool"));
140
+ assert!(!actual.contains("<forge_tool_call"));
141
+ assert!(!actual.contains("Content"));
142
+ assert!(!actual.contains("More"));
143
+ }
144
+
145
+ #[test]
146
+ fn test_remove_tag_with_prefix() {
147
+ let fixture = "<forge_task>Task details</forge_task> Regular text <forge_analysis>Analysis details</forge_analysis>";
148
+ let actual = remove_tag_with_prefix(fixture, "forge_");
149
+ let expected = " Regular text ";
150
+ assert_eq!(actual, expected);
151
+ }
152
+
153
+ #[test]
154
+ fn test_remove_tag_with_prefix_no_matching_tags() {
155
+ let fixture = "<other>Content</other> <another>More content</another>";
156
+ let actual = remove_tag_with_prefix(fixture, "forge_");
157
+ let expected = "<other>Content</other> <another>More content</another>";
158
+ assert_eq!(actual, expected);
159
+ }
160
+
161
+ #[test]
162
+ fn test_with_duplicate_closing_tags() {
163
+ let fixture = "<foo>1<foo>2</foo>3</foo>";
164
+ let actual = extract_tag_content(fixture, "foo").unwrap();
165
+ let expected = "1<foo>2</foo>3";
166
+ assert_eq!(actual, expected);
167
+ }
168
+ }
crates/forge_domain/tests/test_stringified_tool_calls.rs ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Integration test for stringified tool call arguments fix
2
+ //!
3
+ //! This test verifies that when the API sends tool call arguments as a string
4
+ //! containing JSON (like kimi-k2p5-turbo does), we properly parse it and
5
+ //! serialize it back as a JSON object when sending to the API.
6
+
7
+ use forge_domain::{Context, ContextMessage, Role};
8
+
9
+ /// Test that stringified tool call arguments from API are properly handled
10
+ ///
11
+ /// This simulates the exact scenario: API sends arguments as a string
12
+ /// containing JSON, and when we serialize the conversation back to send to API,
13
+ /// it should be a proper object.
14
+ #[test]
15
+ fn test_stringified_tool_call_arguments_roundtrip() {
16
+ // Simulate what kimi-k2p5-turbo sends: arguments as a string containing JSON
17
+ // Note: This is what the API sends us - a JSON string value containing JSON
18
+ // object
19
+ let conversation_json = r#"{
20
+ "messages": [
21
+ {
22
+ "text": {
23
+ "role": "System",
24
+ "content": "You are Forge."
25
+ }
26
+ },
27
+ {
28
+ "text": {
29
+ "role": "User",
30
+ "content": "Read a file"
31
+ }
32
+ },
33
+ {
34
+ "text": {
35
+ "role": "Assistant",
36
+ "content": "I'll read the file.",
37
+ "tool_calls": [
38
+ {
39
+ "name": "read",
40
+ "call_id": "call_001",
41
+ "arguments": "{\"file_path\": \"/test/path\", \"range\": {\"start_line\": 1, \"end_line\": 10}}"
42
+ }
43
+ ]
44
+ }
45
+ }
46
+ ]
47
+ }"#;
48
+
49
+ // Deserialize the conversation (this is what happens when we receive from API)
50
+ let context: Context =
51
+ serde_json::from_str(conversation_json).expect("Failed to parse conversation");
52
+
53
+ // Find the assistant message with tool calls
54
+ let assistant_msg = context
55
+ .messages
56
+ .iter()
57
+ .find_map(|entry| match &entry.message {
58
+ ContextMessage::Text(text) if text.role == Role::Assistant => Some(text),
59
+ _ => None,
60
+ })
61
+ .expect("Should have assistant message");
62
+
63
+ let tool_calls = assistant_msg
64
+ .tool_calls
65
+ .as_ref()
66
+ .expect("Should have tool calls");
67
+ assert_eq!(tool_calls.len(), 1);
68
+
69
+ let tool_call = &tool_calls[0];
70
+ assert_eq!(tool_call.name.as_str(), "read");
71
+
72
+ // Verify arguments are parsed correctly (can access fields)
73
+ let parsed_args = tool_call.arguments.parse().expect("Should parse arguments");
74
+ assert_eq!(parsed_args["file_path"], "/test/path");
75
+ assert_eq!(parsed_args["range"]["start_line"], 1);
76
+
77
+ // Now serialize the context back to JSON (as we would send to API)
78
+ let serialized = serde_json::to_string(&context).expect("Should serialize");
79
+ println!("Serialized: {}", serialized);
80
+
81
+ // Parse the JSON to verify structure
82
+ let reparsed: serde_json::Value = serde_json::from_str(&serialized).expect("Should re-parse");
83
+
84
+ // Find the assistant message
85
+ let messages = reparsed["messages"]
86
+ .as_array()
87
+ .expect("Should have messages");
88
+ let assistant_json = messages
89
+ .iter()
90
+ .find(|m| m["text"]["role"] == "Assistant")
91
+ .expect("Should find assistant message");
92
+
93
+ let tool_calls_json = assistant_json["text"]["tool_calls"]
94
+ .as_array()
95
+ .expect("Should have tool_calls");
96
+
97
+ let args = &tool_calls_json[0]["arguments"];
98
+
99
+ // THE KEY TEST: arguments should be a JSON object, not a string
100
+ // This is what Fireworks API expects - if it's a string, we get 400 error
101
+ assert!(
102
+ args.is_object(),
103
+ "CRITICAL: arguments must be a JSON object for API, not a string. Got: {}",
104
+ args
105
+ );
106
+
107
+ // Verify the values are preserved correctly
108
+ assert_eq!(args["file_path"], "/test/path");
109
+ assert_eq!(args["range"]["start_line"], 1);
110
+ assert_eq!(args["range"]["end_line"], 10);
111
+
112
+ println!("SUCCESS: Stringified arguments properly converted to JSON object");
113
+ }
114
+
115
+ /// Test with patch tool (the actual error case from kimi-k2p5-turbo)
116
+ #[test]
117
+ fn test_kimi_k2p5_turbo_patch_tool_scenario() {
118
+ // This simulates the exact error case: patch tool with stringified arguments
119
+ let conversation_json = r#"{
120
+ "messages": [
121
+ {
122
+ "text": {
123
+ "role": "System",
124
+ "content": "You are Forge."
125
+ }
126
+ },
127
+ {
128
+ "text": {
129
+ "role": "User",
130
+ "content": "Edit the file"
131
+ }
132
+ },
133
+ {
134
+ "text": {
135
+ "role": "Assistant",
136
+ "content": "I'll edit the file.",
137
+ "tool_calls": [
138
+ {
139
+ "name": "patch",
140
+ "call_id": "call_patch_001",
141
+ "arguments": "{\"file_path\": \"/test/file.rs\", \"old_string\": \"fn main() {\\n}\", \"new_string\": \"fn main() {\\n println!(\\\"Hello\\\");\\n}\"}"
142
+ }
143
+ ]
144
+ }
145
+ }
146
+ ]
147
+ }"#;
148
+
149
+ let context: Context =
150
+ serde_json::from_str(conversation_json).expect("Failed to parse conversation");
151
+
152
+ // Serialize back to what we'd send to API
153
+ let serialized = serde_json::to_string(&context).expect("Should serialize");
154
+ let reparsed: serde_json::Value = serde_json::from_str(&serialized).expect("Should parse");
155
+
156
+ let messages = reparsed["messages"]
157
+ .as_array()
158
+ .expect("Should have messages");
159
+ let assistant = messages
160
+ .iter()
161
+ .find(|m| m["text"]["role"] == "Assistant")
162
+ .expect("Should find assistant");
163
+
164
+ let tool_calls = assistant["text"]["tool_calls"]
165
+ .as_array()
166
+ .expect("Should have tool_calls");
167
+ let args = &tool_calls[0]["arguments"];
168
+
169
+ // THE CRITICAL ASSERTION for the kimi-k2p5-turbo fix
170
+ assert!(
171
+ args.is_object(),
172
+ "Tool call arguments must be a JSON object when sent to API, not a string. Got: {}",
173
+ args
174
+ );
175
+
176
+ // Verify nested strings with special characters are preserved
177
+ assert_eq!(args["file_path"], "/test/file.rs");
178
+ assert_eq!(args["old_string"], "fn main() {\n}");
179
+ assert_eq!(
180
+ args["new_string"],
181
+ "fn main() {\n println!(\"Hello\");\n}"
182
+ );
183
+
184
+ println!("SUCCESS: Patch tool with stringified args properly converted");
185
+ }
186
+
187
+ /// Test multiple tool calls with stringified arguments
188
+ #[test]
189
+ fn test_multiple_stringified_tool_calls() {
190
+ let conversation_json = r#"{
191
+ "messages": [
192
+ {
193
+ "text": {
194
+ "role": "System",
195
+ "content": "You are Forge."
196
+ }
197
+ },
198
+ {
199
+ "text": {
200
+ "role": "User",
201
+ "content": "Do multiple things"
202
+ }
203
+ },
204
+ {
205
+ "text": {
206
+ "role": "Assistant",
207
+ "content": "I'll do multiple things.",
208
+ "tool_calls": [
209
+ {
210
+ "name": "read",
211
+ "call_id": "call_1",
212
+ "arguments": "{\"file_path\": \"/file1.txt\"}"
213
+ },
214
+ {
215
+ "name": "patch",
216
+ "call_id": "call_2",
217
+ "arguments": "{\"file_path\": \"/file2.txt\", \"old_string\": \"a\", \"new_string\": \"b\"}"
218
+ }
219
+ ]
220
+ }
221
+ }
222
+ ]
223
+ }"#;
224
+
225
+ let context: Context =
226
+ serde_json::from_str(conversation_json).expect("Failed to parse conversation");
227
+ let serialized = serde_json::to_string(&context).expect("Should serialize");
228
+ let reparsed: serde_json::Value = serde_json::from_str(&serialized).expect("Should parse");
229
+
230
+ let messages = reparsed["messages"]
231
+ .as_array()
232
+ .expect("Should have messages");
233
+ let assistant = messages
234
+ .iter()
235
+ .find(|m| m["text"]["role"] == "Assistant")
236
+ .expect("Should find assistant");
237
+
238
+ let tool_calls = assistant["text"]["tool_calls"]
239
+ .as_array()
240
+ .expect("Should have tool_calls");
241
+
242
+ assert_eq!(tool_calls.len(), 2);
243
+
244
+ // Both tool calls should have arguments as objects, not strings
245
+ for (i, tc) in tool_calls.iter().enumerate() {
246
+ let args = &tc["arguments"];
247
+ assert!(
248
+ args.is_object(),
249
+ "Tool call {} arguments should be a JSON object, got: {}",
250
+ i,
251
+ args
252
+ );
253
+ }
254
+
255
+ println!("SUCCESS: Multiple stringified tool calls properly converted to JSON objects");
256
+ }
257
+
258
+ /// Test that regular JSON objects (not stringified) still work correctly
259
+ #[test]
260
+ fn test_regular_json_objects_unchanged() {
261
+ // Normal case: arguments are already proper JSON objects
262
+ let conversation_json = r#"{
263
+ "messages": [
264
+ {
265
+ "text": {
266
+ "role": "System",
267
+ "content": "You are Forge."
268
+ }
269
+ },
270
+ {
271
+ "text": {
272
+ "role": "Assistant",
273
+ "content": "I'll read the file.",
274
+ "tool_calls": [
275
+ {
276
+ "name": "read",
277
+ "call_id": "call_001",
278
+ "arguments": {"file_path": "/test/path", "range": {"start_line": 1, "end_line": 10}}
279
+ }
280
+ ]
281
+ }
282
+ }
283
+ ]
284
+ }"#;
285
+
286
+ let context: Context =
287
+ serde_json::from_str(conversation_json).expect("Failed to parse conversation");
288
+ let serialized = serde_json::to_string(&context).expect("Should serialize");
289
+ let reparsed: serde_json::Value = serde_json::from_str(&serialized).expect("Should parse");
290
+
291
+ let messages = reparsed["messages"]
292
+ .as_array()
293
+ .expect("Should have messages");
294
+ let assistant = messages
295
+ .iter()
296
+ .find(|m| m["text"]["role"] == "Assistant")
297
+ .expect("Should find assistant");
298
+
299
+ let tool_calls = assistant["text"]["tool_calls"]
300
+ .as_array()
301
+ .expect("Should have tool_calls");
302
+ let args = &tool_calls[0]["arguments"];
303
+
304
+ // Should still be an object
305
+ assert!(
306
+ args.is_object(),
307
+ "Regular JSON objects should remain as objects"
308
+ );
309
+ assert_eq!(args["file_path"], "/test/path");
310
+ assert_eq!(args["range"]["start_line"], 1);
311
+
312
+ println!("SUCCESS: Regular JSON objects work correctly");
313
+ }
crates/forge_eventsource_stream/src/event.rs ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #[cfg(not(feature = "std"))]
2
+ use alloc::string::String;
3
+ use core::time::Duration;
4
+
5
+ /// An Event
6
+ #[derive(Default, Debug, Eq, PartialEq, Clone)]
7
+ pub struct Event {
8
+ /// The event name if given
9
+ pub event: String,
10
+ /// The event data
11
+ pub data: String,
12
+ /// The event id if given
13
+ pub id: String,
14
+ /// Retry duration if given
15
+ pub retry: Option<Duration>,
16
+ }
crates/forge_eventsource_stream/src/event_stream.rs ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #[cfg(not(feature = "std"))]
2
+ use alloc::string::{FromUtf8Error, String, ToString};
3
+ use core::fmt;
4
+ use core::pin::Pin;
5
+ use core::time::Duration;
6
+ #[cfg(feature = "std")]
7
+ use std::string::FromUtf8Error;
8
+
9
+ use futures_core::stream::Stream;
10
+ use futures_core::task::{Context, Poll};
11
+ use nom::error::Error as NomError;
12
+ use pin_project_lite::pin_project;
13
+
14
+ use crate::event::Event;
15
+ use crate::parser::{RawEventLine, is_bom, is_lf, line};
16
+ use crate::utf8_stream::{Utf8Stream, Utf8StreamError};
17
+
18
+ #[derive(Default, Debug)]
19
+ struct EventBuilder {
20
+ event: Event,
21
+ is_complete: bool,
22
+ }
23
+
24
+ impl EventBuilder {
25
+ /// From the HTML spec
26
+ ///
27
+ /// -> If the field name is "event"
28
+ /// Set the event type buffer to field value.
29
+ ///
30
+ /// -> If the field name is "data"
31
+ /// Append the field value to the data buffer, then append a single
32
+ /// U+000A LINE FEED (LF) character to the data buffer.
33
+ ///
34
+ /// -> If the field name is "id"
35
+ /// If the field value does not contain U+0000 NULL, then set the last
36
+ /// event ID buffer to the field value. Otherwise, ignore the field.
37
+ ///
38
+ /// -> If the field name is "retry"
39
+ /// If the field value consists of only ASCII digits, then interpret the
40
+ /// field value as an integer in base ten, and set the event stream's
41
+ /// reconnection time to that integer. Otherwise, ignore the field.
42
+ ///
43
+ /// -> Otherwise
44
+ /// The field is ignored.
45
+ fn add(&mut self, line: RawEventLine) {
46
+ match line {
47
+ RawEventLine::Field(field, val) => {
48
+ let val = val.unwrap_or("");
49
+ match field {
50
+ "event" => {
51
+ self.event.event = val.to_string();
52
+ }
53
+ "data" => {
54
+ self.event.data.push_str(val);
55
+ self.event.data.push('\u{000A}');
56
+ }
57
+ "id" if !val.contains('\u{0000}') => self.event.id = val.to_string(),
58
+ "retry" => {
59
+ if let Ok(val) = val.parse::<u64>() {
60
+ self.event.retry = Some(Duration::from_millis(val))
61
+ }
62
+ }
63
+ _ => {}
64
+ }
65
+ }
66
+ RawEventLine::Comment(_) => {}
67
+ RawEventLine::Empty => self.is_complete = true,
68
+ }
69
+ }
70
+
71
+ /// From the HTML spec
72
+ ///
73
+ /// 1. Set the last event ID string of the event source to the value of the
74
+ /// last event ID buffer. The buffer does not get reset, so the last
75
+ /// event ID string of the event source remains set to this value until
76
+ /// the next time it is set by the server.
77
+ /// 2. If the data buffer is an empty string, set the data buffer and the
78
+ /// event type buffer to the empty string and return.
79
+ /// 3. If the data buffer's last character is a U+000A LINE FEED (LF)
80
+ /// character, then remove the last character from the data buffer.
81
+ /// 4. Let event be the result of creating an event using MessageEvent, in
82
+ /// the relevant Realm of the EventSource object.
83
+ /// 5. Initialize event's type attribute to message, its data attribute to
84
+ /// data, its origin attribute to the serialization of the origin of the
85
+ /// event stream's final URL (i.e., the URL after redirects), and its
86
+ /// lastEventId attribute to the last event ID string of the event
87
+ /// source.
88
+ /// 6. If the event type buffer has a value other than the empty string,
89
+ /// change the type of the newly created event to equal the value of the
90
+ /// event type buffer.
91
+ /// 7. Set the data buffer and the event type buffer to the empty string.
92
+ /// 8. Queue a task which, if the readyState attribute is set to a value
93
+ /// other than CLOSED, dispatches the newly created event at the
94
+ /// EventSource object.
95
+ fn dispatch(&mut self) -> Option<Event> {
96
+ let builder = core::mem::take(self);
97
+ let mut event = builder.event;
98
+ self.event.id = event.id.clone();
99
+
100
+ if event.data.is_empty() {
101
+ return None;
102
+ }
103
+
104
+ if is_lf(event.data.chars().next_back().unwrap()) {
105
+ event.data.pop();
106
+ }
107
+
108
+ if event.event.is_empty() {
109
+ event.event = "message".to_string();
110
+ }
111
+
112
+ Some(event)
113
+ }
114
+ }
115
+
116
+ #[derive(Debug, Clone, Copy)]
117
+ pub enum EventStreamState {
118
+ NotStarted,
119
+ Started,
120
+ Terminated,
121
+ }
122
+
123
+ impl EventStreamState {
124
+ fn is_terminated(self) -> bool {
125
+ matches!(self, Self::Terminated)
126
+ }
127
+ fn is_started(self) -> bool {
128
+ matches!(self, Self::Started)
129
+ }
130
+ }
131
+
132
+ pin_project! {
133
+ /// A Stream of events
134
+ pub struct EventStream<S> {
135
+ #[pin]
136
+ stream: Utf8Stream<S>,
137
+ buffer: String,
138
+ builder: EventBuilder,
139
+ state: EventStreamState,
140
+ last_event_id: String,
141
+ }
142
+ }
143
+
144
+ impl<S> EventStream<S> {
145
+ /// Initialize the EventStream with a Stream
146
+ pub fn new(stream: S) -> Self {
147
+ Self {
148
+ stream: Utf8Stream::new(stream),
149
+ buffer: String::new(),
150
+ builder: EventBuilder::default(),
151
+ state: EventStreamState::NotStarted,
152
+ last_event_id: String::new(),
153
+ }
154
+ }
155
+
156
+ /// Set the last event ID of the stream. Useful for initializing the stream
157
+ /// with a previous last event ID
158
+ pub fn set_last_event_id(&mut self, id: impl Into<String>) {
159
+ self.last_event_id = id.into();
160
+ }
161
+
162
+ /// Get the last event ID of the stream
163
+ pub fn last_event_id(&self) -> &str {
164
+ &self.last_event_id
165
+ }
166
+ }
167
+
168
+ /// Error thrown while parsing an event line
169
+ #[derive(Debug, PartialEq)]
170
+ pub enum EventStreamError<E> {
171
+ /// Source stream is not valid UTF8
172
+ Utf8(FromUtf8Error),
173
+ /// Source stream is not a valid EventStream
174
+ Parser(NomError<String>),
175
+ /// Underlying source stream error
176
+ Transport(E),
177
+ }
178
+
179
+ impl<E> From<Utf8StreamError<E>> for EventStreamError<E> {
180
+ fn from(err: Utf8StreamError<E>) -> Self {
181
+ match err {
182
+ Utf8StreamError::Utf8(err) => Self::Utf8(err),
183
+ Utf8StreamError::Transport(err) => Self::Transport(err),
184
+ }
185
+ }
186
+ }
187
+
188
+ impl<E> From<NomError<&str>> for EventStreamError<E> {
189
+ fn from(err: NomError<&str>) -> Self {
190
+ EventStreamError::Parser(NomError::new(err.input.to_string(), err.code))
191
+ }
192
+ }
193
+
194
+ impl<E> fmt::Display for EventStreamError<E>
195
+ where
196
+ E: fmt::Display,
197
+ {
198
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199
+ match self {
200
+ Self::Utf8(err) => f.write_fmt(format_args!("UTF8 error: {}", err)),
201
+ Self::Parser(err) => f.write_fmt(format_args!("Parse error: {}", err)),
202
+ Self::Transport(err) => f.write_fmt(format_args!("Transport error: {}", err)),
203
+ }
204
+ }
205
+ }
206
+
207
+ #[cfg(feature = "std")]
208
+ impl<E> std::error::Error for EventStreamError<E> where E: fmt::Display + fmt::Debug + Send + Sync {}
209
+
210
+ fn parse_event<E>(
211
+ buffer: &mut String,
212
+ builder: &mut EventBuilder,
213
+ ) -> Result<Option<Event>, EventStreamError<E>> {
214
+ if buffer.is_empty() {
215
+ return Ok(None);
216
+ }
217
+ loop {
218
+ match line(buffer.as_ref()) {
219
+ Ok((rem, next_line)) => {
220
+ builder.add(next_line);
221
+ let consumed = buffer.len() - rem.len();
222
+ let rem = buffer.split_off(consumed);
223
+ *buffer = rem;
224
+ if builder.is_complete
225
+ && let Some(event) = builder.dispatch()
226
+ {
227
+ return Ok(Some(event));
228
+ }
229
+ }
230
+ Err(nom::Err::Incomplete(_)) => return Ok(None),
231
+ Err(nom::Err::Error(err)) | Err(nom::Err::Failure(err)) => return Err(err.into()),
232
+ }
233
+ }
234
+ }
235
+
236
+ impl<S, B, E> Stream for EventStream<S>
237
+ where
238
+ S: Stream<Item = Result<B, E>>,
239
+ B: AsRef<[u8]>,
240
+ {
241
+ type Item = Result<Event, EventStreamError<E>>;
242
+
243
+ fn poll_next(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<Self::Item>> {
244
+ let mut this = self.project();
245
+
246
+ match parse_event(this.buffer, this.builder) {
247
+ Ok(Some(event)) => {
248
+ *this.last_event_id = event.id.clone();
249
+ return Poll::Ready(Some(Ok(event)));
250
+ }
251
+ Err(err) => return Poll::Ready(Some(Err(err))),
252
+ _ => {}
253
+ }
254
+
255
+ if this.state.is_terminated() {
256
+ return Poll::Ready(None);
257
+ }
258
+
259
+ loop {
260
+ match this.stream.as_mut().poll_next(cx) {
261
+ Poll::Ready(Some(Ok(string))) => {
262
+ if string.is_empty() {
263
+ continue;
264
+ }
265
+
266
+ let slice = if this.state.is_started() {
267
+ string.as_str()
268
+ } else {
269
+ *this.state = EventStreamState::Started;
270
+ string.strip_prefix(is_bom).unwrap_or(&string)
271
+ };
272
+ this.buffer.push_str(slice);
273
+
274
+ match parse_event(this.buffer, this.builder) {
275
+ Ok(Some(event)) => {
276
+ *this.last_event_id = event.id.clone();
277
+ return Poll::Ready(Some(Ok(event)));
278
+ }
279
+ Err(err) => return Poll::Ready(Some(Err(err))),
280
+ _ => {}
281
+ }
282
+ }
283
+ Poll::Ready(Some(Err(err))) => return Poll::Ready(Some(Err(err.into()))),
284
+ Poll::Ready(None) => {
285
+ *this.state = EventStreamState::Terminated;
286
+ return Poll::Ready(None);
287
+ }
288
+ Poll::Pending => return Poll::Pending,
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ #[cfg(test)]
295
+ mod tests {
296
+ use futures::prelude::*;
297
+
298
+ use super::*;
299
+
300
+ #[tokio::test]
301
+ async fn valid_data_fields() {
302
+ assert_eq!(
303
+ EventStream::new(futures::stream::iter(vec![Ok::<_, ()>(
304
+ "data: Hello, world!\n\n"
305
+ )]))
306
+ .try_collect::<Vec<_>>()
307
+ .await
308
+ .unwrap(),
309
+ vec![Event {
310
+ event: "message".to_string(),
311
+ data: "Hello, world!".to_string(),
312
+ ..Default::default()
313
+ }]
314
+ );
315
+ assert_eq!(
316
+ EventStream::new(futures::stream::iter(vec![
317
+ Ok::<_, ()>("data: Hello,"),
318
+ Ok::<_, ()>(" world!\n\n")
319
+ ]))
320
+ .try_collect::<Vec<_>>()
321
+ .await
322
+ .unwrap(),
323
+ vec![Event {
324
+ event: "message".to_string(),
325
+ data: "Hello, world!".to_string(),
326
+ ..Default::default()
327
+ }]
328
+ );
329
+ assert_eq!(
330
+ EventStream::new(futures::stream::iter(vec![
331
+ Ok::<_, ()>("data: Hello,"),
332
+ Ok::<_, ()>(""),
333
+ Ok::<_, ()>(" world!\n\n")
334
+ ]))
335
+ .try_collect::<Vec<_>>()
336
+ .await
337
+ .unwrap(),
338
+ vec![Event {
339
+ event: "message".to_string(),
340
+ data: "Hello, world!".to_string(),
341
+ ..Default::default()
342
+ }]
343
+ );
344
+ assert_eq!(
345
+ EventStream::new(futures::stream::iter(vec![Ok::<_, ()>(
346
+ "data: Hello, world!\n"
347
+ )]))
348
+ .try_collect::<Vec<_>>()
349
+ .await
350
+ .unwrap(),
351
+ vec![]
352
+ );
353
+ assert_eq!(
354
+ EventStream::new(futures::stream::iter(vec![Ok::<_, ()>(
355
+ "data: Hello,\ndata: world!\n\n"
356
+ )]))
357
+ .try_collect::<Vec<_>>()
358
+ .await
359
+ .unwrap(),
360
+ vec![Event {
361
+ event: "message".to_string(),
362
+ data: "Hello,\nworld!".to_string(),
363
+ ..Default::default()
364
+ }]
365
+ );
366
+ assert_eq!(
367
+ EventStream::new(futures::stream::iter(vec![Ok::<_, ()>(
368
+ "data: Hello,\n\ndata: world!\n\n"
369
+ )]))
370
+ .try_collect::<Vec<_>>()
371
+ .await
372
+ .unwrap(),
373
+ vec![
374
+ Event {
375
+ event: "message".to_string(),
376
+ data: "Hello,".to_string(),
377
+ ..Default::default()
378
+ },
379
+ Event {
380
+ event: "message".to_string(),
381
+ data: "world!".to_string(),
382
+ ..Default::default()
383
+ }
384
+ ]
385
+ );
386
+ }
387
+
388
+ #[tokio::test]
389
+ async fn spec_examples() {
390
+ assert_eq!(
391
+ EventStream::new(futures::stream::iter(vec![Ok::<_, ()>(
392
+ "data: This is the first message.
393
+
394
+ data: This is the second message, it
395
+ data: has two lines.
396
+
397
+ data: This is the third message.
398
+
399
+ "
400
+ )]))
401
+ .try_collect::<Vec<_>>()
402
+ .await
403
+ .unwrap(),
404
+ vec![
405
+ Event {
406
+ event: "message".to_string(),
407
+ data: "This is the first message.".to_string(),
408
+ ..Default::default()
409
+ },
410
+ Event {
411
+ event: "message".to_string(),
412
+ data: "This is the second message, it\nhas two lines.".to_string(),
413
+ ..Default::default()
414
+ },
415
+ Event {
416
+ event: "message".to_string(),
417
+ data: "This is the third message.".to_string(),
418
+ ..Default::default()
419
+ }
420
+ ]
421
+ );
422
+ assert_eq!(
423
+ EventStream::new(futures::stream::iter(vec![Ok::<_, ()>(
424
+ "event: add
425
+ data: 73857293
426
+
427
+ event: remove
428
+ data: 2153
429
+
430
+ event: add
431
+ data: 113411
432
+
433
+ "
434
+ )]))
435
+ .try_collect::<Vec<_>>()
436
+ .await
437
+ .unwrap(),
438
+ vec![
439
+ Event {
440
+ event: "add".to_string(),
441
+ data: "73857293".to_string(),
442
+ ..Default::default()
443
+ },
444
+ Event {
445
+ event: "remove".to_string(),
446
+ data: "2153".to_string(),
447
+ ..Default::default()
448
+ },
449
+ Event {
450
+ event: "add".to_string(),
451
+ data: "113411".to_string(),
452
+ ..Default::default()
453
+ }
454
+ ]
455
+ );
456
+ assert_eq!(
457
+ EventStream::new(futures::stream::iter(vec![Ok::<_, ()>(
458
+ "data: YHOO
459
+ data: +2
460
+ data: 10
461
+
462
+ "
463
+ )]))
464
+ .try_collect::<Vec<_>>()
465
+ .await
466
+ .unwrap(),
467
+ vec![Event {
468
+ event: "message".to_string(),
469
+ data: "YHOO\n+2\n10".to_string(),
470
+ ..Default::default()
471
+ },]
472
+ );
473
+ assert_eq!(
474
+ EventStream::new(futures::stream::iter(vec![Ok::<_, ()>(
475
+ ": test stream
476
+
477
+ data: first event
478
+ id: 1
479
+
480
+ data:second event
481
+ id
482
+
483
+ data: third event
484
+
485
+ "
486
+ )]))
487
+ .try_collect::<Vec<_>>()
488
+ .await
489
+ .unwrap(),
490
+ vec![
491
+ Event {
492
+ event: "message".to_string(),
493
+ id: "1".to_string(),
494
+ data: "first event".to_string(),
495
+ ..Default::default()
496
+ },
497
+ Event {
498
+ event: "message".to_string(),
499
+ data: "second event".to_string(),
500
+ ..Default::default()
501
+ },
502
+ Event {
503
+ event: "message".to_string(),
504
+ data: " third event".to_string(),
505
+ ..Default::default()
506
+ }
507
+ ]
508
+ );
509
+ assert_eq!(
510
+ EventStream::new(futures::stream::iter(vec![Ok::<_, ()>(
511
+ "data
512
+
513
+ data
514
+ data
515
+
516
+ data:
517
+ "
518
+ )]))
519
+ .try_collect::<Vec<_>>()
520
+ .await
521
+ .unwrap(),
522
+ vec![
523
+ Event {
524
+ event: "message".to_string(),
525
+ data: "".to_string(),
526
+ ..Default::default()
527
+ },
528
+ Event {
529
+ event: "message".to_string(),
530
+ data: "\n".to_string(),
531
+ ..Default::default()
532
+ },
533
+ ]
534
+ );
535
+ assert_eq!(
536
+ EventStream::new(futures::stream::iter(vec![Ok::<_, ()>(
537
+ "data:test
538
+
539
+ data: test
540
+
541
+ "
542
+ )]))
543
+ .try_collect::<Vec<_>>()
544
+ .await
545
+ .unwrap(),
546
+ vec![
547
+ Event {
548
+ event: "message".to_string(),
549
+ data: "test".to_string(),
550
+ ..Default::default()
551
+ },
552
+ Event {
553
+ event: "message".to_string(),
554
+ data: "test".to_string(),
555
+ ..Default::default()
556
+ },
557
+ ]
558
+ );
559
+ }
560
+ }
crates/forge_eventsource_stream/src/lib.rs ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! A basic building block for building an Eventsource from a Stream of bytes
2
+ //! array like objects. To learn more about Server Sent Events (SSE) take a look
3
+ //! at [the MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)
4
+ //!
5
+ //! # Example
6
+ //!
7
+ //! ```ignore
8
+ //! let mut stream = reqwest::Client::new()
9
+ //! .get("http://localhost:7020/notifications")
10
+ //! .send()
11
+ //! .await?
12
+ //! .bytes_stream()
13
+ //! .eventsource();
14
+ //!
15
+ //!
16
+ //! while let Some(event) = stream.next().await {
17
+ //! match event {
18
+ //! Ok(event) => println!(
19
+ //! "received event[type={}]: {}",
20
+ //! event.event,
21
+ //! event.data
22
+ //! ),
23
+ //! Err(e) => eprintln!("error occured: {}", e),
24
+ //! }
25
+ //! }
26
+ //! ```
27
+
28
+ #![cfg_attr(not(feature = "std"), no_std)]
29
+
30
+ #[cfg(not(feature = "std"))]
31
+ extern crate alloc;
32
+
33
+ mod event;
34
+ mod event_stream;
35
+ mod parser;
36
+ mod traits;
37
+ mod utf8_stream;
38
+
39
+ pub use event::Event;
40
+ pub use event_stream::{EventStream, EventStreamError};
41
+ pub use traits::Eventsource;
crates/forge_eventsource_stream/src/parser.rs ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use nom::branch::alt;
2
+ use nom::bytes::complete::take_while_m_n as take_while_m_n_complete;
3
+ use nom::bytes::streaming::{tag, take_while, take_while_m_n, take_while1};
4
+ use nom::combinator::opt;
5
+ use nom::sequence::{preceded, terminated};
6
+ use nom::{IResult, Parser};
7
+
8
+ /// ; ABNF definition from HTML spec
9
+ ///
10
+ /// stream = [ bom ] *event
11
+ /// event = *( comment / field ) end-of-line
12
+ /// comment = colon *any-char end-of-line
13
+ /// field = 1*name-char [ colon [ space ] *any-char ] end-of-line
14
+ /// end-of-line = ( cr lf / cr / lf )
15
+ ///
16
+ /// ; characters
17
+ /// lf = %x000A ; U+000A LINE FEED (LF)
18
+ /// cr = %x000D ; U+000D CARRIAGE RETURN (CR)
19
+ /// space = %x0020 ; U+0020 SPACE
20
+ /// colon = %x003A ; U+003A COLON (:)
21
+ /// bom = %xFEFF ; U+FEFF BYTE ORDER MARK
22
+ /// name-char = %x0000-0009 / %x000B-000C / %x000E-0039 / %x003B-10FFFF
23
+ /// ; a scalar value other than U+000A LINE FEED (LF), U+000D
24
+ /// CARRIAGE RETURN (CR), or U+003A COLON (:) any-char = %x0000-0009 /
25
+ /// %x000B-000C / %x000E-10FFFF ; a scalar value other than
26
+ /// U+000A LINE FEED (LF) or U+000D CARRIAGE RETURN (CR)
27
+
28
+ #[derive(Debug)]
29
+ #[allow(dead_code)]
30
+ pub enum RawEventLine<'a> {
31
+ Comment(&'a str),
32
+ Field(&'a str, Option<&'a str>),
33
+ Empty,
34
+ }
35
+
36
+ #[inline]
37
+ pub fn is_lf(c: char) -> bool {
38
+ c == '\u{000A}'
39
+ }
40
+
41
+ #[inline]
42
+ pub fn is_cr(c: char) -> bool {
43
+ c == '\u{000D}'
44
+ }
45
+
46
+ #[inline]
47
+ pub fn is_space(c: char) -> bool {
48
+ c == '\u{0020}'
49
+ }
50
+
51
+ #[inline]
52
+ pub fn is_colon(c: char) -> bool {
53
+ c == '\u{003A}'
54
+ }
55
+
56
+ #[inline]
57
+ pub fn is_bom(c: char) -> bool {
58
+ c == '\u{feff}'
59
+ }
60
+
61
+ #[inline]
62
+ pub fn is_name_char(c: char) -> bool {
63
+ matches!(c, '\u{0000}'..='\u{0009}'
64
+ | '\u{000B}'..='\u{000C}'
65
+ | '\u{000E}'..='\u{0039}'
66
+ | '\u{003B}'..='\u{10FFFF}')
67
+ }
68
+
69
+ #[inline]
70
+ pub fn is_any_char(c: char) -> bool {
71
+ matches!(c, '\u{0000}'..='\u{0009}'
72
+ | '\u{000B}'..='\u{000C}'
73
+ | '\u{000E}'..='\u{10FFFF}')
74
+ }
75
+
76
+ #[inline]
77
+ fn crlf(input: &str) -> IResult<&str, &str> {
78
+ tag("\u{000D}\u{000A}").parse(input)
79
+ }
80
+
81
+ #[inline]
82
+ fn end_of_line(input: &str) -> IResult<&str, &str> {
83
+ alt((
84
+ crlf,
85
+ take_while_m_n_complete(1, 1, is_cr),
86
+ take_while_m_n_complete(1, 1, is_lf),
87
+ ))
88
+ .parse(input)
89
+ }
90
+
91
+ #[inline]
92
+ fn comment(input: &str) -> IResult<&str, RawEventLine<'_>> {
93
+ preceded(
94
+ take_while_m_n(1, 1, is_colon),
95
+ terminated(take_while(is_any_char), end_of_line),
96
+ )
97
+ .parse(input)
98
+ .map(|(input, comment)| (input, RawEventLine::Comment(comment)))
99
+ }
100
+
101
+ #[inline]
102
+ fn field(input: &str) -> IResult<&str, RawEventLine<'_>> {
103
+ terminated(
104
+ (
105
+ take_while1(is_name_char),
106
+ opt(preceded(
107
+ take_while_m_n(1, 1, is_colon),
108
+ preceded(opt(take_while_m_n(1, 1, is_space)), take_while(is_any_char)),
109
+ )),
110
+ ),
111
+ end_of_line,
112
+ )
113
+ .parse(input)
114
+ .map(|(input, (field, data))| (input, RawEventLine::Field(field, data)))
115
+ }
116
+
117
+ #[inline]
118
+ fn empty(input: &str) -> IResult<&str, RawEventLine<'_>> {
119
+ end_of_line(input).map(|(i, _)| (i, RawEventLine::Empty))
120
+ }
121
+
122
+ pub fn line(input: &str) -> IResult<&str, RawEventLine<'_>> {
123
+ alt((comment, field, empty)).parse(input)
124
+ }
crates/forge_eventsource_stream/src/traits.rs ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use futures_core::stream::Stream;
2
+
3
+ use crate::event_stream::EventStream;
4
+
5
+ /// Main entrypoint for creating [`crate::Event`] streams
6
+ pub trait Eventsource: Sized {
7
+ /// Create an event stream from a stream of bytes
8
+ fn eventsource(self) -> EventStream<Self>;
9
+ }
10
+
11
+ impl<S, B, E> Eventsource for S
12
+ where
13
+ S: Stream<Item = Result<B, E>>,
14
+ B: AsRef<[u8]>,
15
+ {
16
+ fn eventsource(self) -> EventStream<Self> {
17
+ EventStream::new(self)
18
+ }
19
+ }
crates/forge_eventsource_stream/src/utf8_stream.rs ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #[cfg(not(feature = "std"))]
2
+ use alloc::{
3
+ string::{FromUtf8Error, String},
4
+ vec::Vec,
5
+ };
6
+ use core::pin::Pin;
7
+ #[cfg(feature = "std")]
8
+ use std::string::FromUtf8Error;
9
+
10
+ use futures_core::stream::Stream;
11
+ use futures_core::task::{Context, Poll};
12
+ use pin_project_lite::pin_project;
13
+
14
+ pin_project! {
15
+ pub struct Utf8Stream<S> {
16
+ #[pin]
17
+ stream: S,
18
+ buffer: Vec<u8>,
19
+ terminated: bool,
20
+ }
21
+ }
22
+
23
+ impl<S> Utf8Stream<S> {
24
+ pub fn new(stream: S) -> Self {
25
+ Self { stream, buffer: Vec::new(), terminated: false }
26
+ }
27
+ }
28
+
29
+ #[derive(Debug, PartialEq)]
30
+ pub enum Utf8StreamError<E> {
31
+ Utf8(FromUtf8Error),
32
+ Transport(E),
33
+ }
34
+
35
+ impl<E> From<FromUtf8Error> for Utf8StreamError<E> {
36
+ fn from(err: FromUtf8Error) -> Self {
37
+ Self::Utf8(err)
38
+ }
39
+ }
40
+
41
+ impl<S, B, E> Stream for Utf8Stream<S>
42
+ where
43
+ S: Stream<Item = Result<B, E>>,
44
+ B: AsRef<[u8]>,
45
+ {
46
+ type Item = Result<String, Utf8StreamError<E>>;
47
+
48
+ fn poll_next(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<Self::Item>> {
49
+ let this = self.project();
50
+ if *this.terminated {
51
+ return Poll::Ready(None);
52
+ }
53
+ match this.stream.poll_next(cx) {
54
+ Poll::Ready(Some(Ok(bytes))) => {
55
+ this.buffer.extend_from_slice(bytes.as_ref());
56
+ let bytes = core::mem::take(this.buffer);
57
+ match String::from_utf8(bytes) {
58
+ Ok(string) => Poll::Ready(Some(Ok(string))),
59
+ Err(err) => {
60
+ let valid_size = err.utf8_error().valid_up_to();
61
+ let mut bytes = err.into_bytes();
62
+ let rem = bytes.split_off(valid_size);
63
+ *this.buffer = rem;
64
+ Poll::Ready(Some(Ok(unsafe { String::from_utf8_unchecked(bytes) })))
65
+ }
66
+ }
67
+ }
68
+ Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(Utf8StreamError::Transport(err)))),
69
+ Poll::Ready(None) => {
70
+ *this.terminated = true;
71
+ if this.buffer.is_empty() {
72
+ Poll::Ready(None)
73
+ } else {
74
+ Poll::Ready(Some(
75
+ String::from_utf8(core::mem::take(this.buffer))
76
+ .map_err(Utf8StreamError::Utf8),
77
+ ))
78
+ }
79
+ }
80
+ Poll::Pending => Poll::Pending,
81
+ }
82
+ }
83
+ }
84
+
85
+ #[cfg(test)]
86
+ mod tests {
87
+ use futures::prelude::*;
88
+
89
+ use super::*;
90
+
91
+ #[tokio::test]
92
+ async fn valid_streams() {
93
+ assert_eq!(
94
+ Utf8Stream::new(futures::stream::iter(vec![Ok::<_, ()>(b"Hello, world!")]))
95
+ .try_collect::<Vec<_>>()
96
+ .await
97
+ .unwrap(),
98
+ vec!["Hello, world!"]
99
+ );
100
+ assert_eq!(
101
+ Utf8Stream::new(futures::stream::iter(vec![Ok::<_, ()>("Hello, world!")]))
102
+ .try_collect::<Vec<_>>()
103
+ .await
104
+ .unwrap(),
105
+ vec!["Hello, world!"]
106
+ );
107
+ assert_eq!(
108
+ Utf8Stream::new(futures::stream::iter(vec![Ok::<_, ()>("")]))
109
+ .try_collect::<Vec<_>>()
110
+ .await
111
+ .unwrap(),
112
+ vec![""]
113
+ );
114
+ assert_eq!(
115
+ Utf8Stream::new(futures::stream::iter(vec![
116
+ Ok::<_, ()>("Hello"),
117
+ Ok::<_, ()>(", world!")
118
+ ]))
119
+ .try_collect::<Vec<_>>()
120
+ .await
121
+ .unwrap(),
122
+ vec!["Hello", ", world!"]
123
+ );
124
+ assert_eq!(
125
+ Utf8Stream::new(futures::stream::iter(vec![Ok::<_, ()>(vec![
126
+ 240, 159, 145, 141
127
+ ]),]))
128
+ .try_collect::<Vec<_>>()
129
+ .await
130
+ .unwrap(),
131
+ vec!["👍"]
132
+ );
133
+ assert_eq!(
134
+ Utf8Stream::new(futures::stream::iter(vec![
135
+ Ok::<_, ()>(vec![240, 159]),
136
+ Ok::<_, ()>(vec![145, 141])
137
+ ]))
138
+ .try_collect::<Vec<_>>()
139
+ .await
140
+ .unwrap(),
141
+ vec!["", "👍"]
142
+ );
143
+ assert_eq!(
144
+ Utf8Stream::new(futures::stream::iter(vec![
145
+ Ok::<_, ()>(vec![240, 159]),
146
+ Ok::<_, ()>(vec![145, 141, 240, 159, 145, 141])
147
+ ]))
148
+ .try_collect::<Vec<_>>()
149
+ .await
150
+ .unwrap(),
151
+ vec!["", "👍👍"]
152
+ );
153
+ }
154
+
155
+ #[tokio::test]
156
+ async fn invalid_streams() {
157
+ let results = Utf8Stream::new(futures::stream::iter(vec![Ok::<_, ()>(vec![240, 159])]))
158
+ .collect::<Vec<_>>()
159
+ .await;
160
+ assert_eq!(results.len(), 2);
161
+ assert_eq!(results[0], Ok("".to_string()));
162
+ assert!(matches!(results[1], Err(Utf8StreamError::Utf8(_))));
163
+
164
+ let results = Utf8Stream::new(futures::stream::iter(vec![
165
+ Ok::<_, ()>(vec![240, 159]),
166
+ Ok::<_, ()>(vec![145, 141, 240, 159, 145]),
167
+ ]))
168
+ .collect::<Vec<_>>()
169
+ .await;
170
+ assert_eq!(results.len(), 3);
171
+ assert_eq!(results[0], Ok("".to_string()));
172
+ assert_eq!(results[1], Ok("👍".to_string()));
173
+ assert!(matches!(results[2], Err(Utf8StreamError::Utf8(_))));
174
+ }
175
+ }
crates/forge_fs/src/binary_detection.rs ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use anyhow::Result;
2
+ use tokio::fs::File;
3
+ use tokio::io::AsyncReadExt;
4
+
5
+ /// Detected encoding types based on BOM analysis
6
+ #[derive(Debug, Clone, PartialEq, Eq)]
7
+ enum Encoding {
8
+ Utf8WithBom,
9
+ Utf16BE,
10
+ Utf16LE,
11
+ }
12
+
13
+ impl Encoding {
14
+ /// Detects BOM (Byte Order Mark) patterns
15
+ pub fn detect(buffer: &[u8], bytes_read: usize) -> Option<Self> {
16
+ match (buffer.first(), buffer.get(1), buffer.get(2)) {
17
+ (Some(&0xEF), Some(&0xBB), Some(&0xBF)) if bytes_read >= 3 => {
18
+ Some(Encoding::Utf8WithBom)
19
+ }
20
+ (Some(&0xFE), Some(&0xFF), _) if bytes_read >= 2 => Some(Encoding::Utf16BE),
21
+ (Some(&0xFF), Some(&0xFE), _) if bytes_read >= 2 => Some(Encoding::Utf16LE),
22
+ _ => None,
23
+ }
24
+ }
25
+ }
26
+
27
+ /// Detects if a file is binary by analyzing its content
28
+ pub async fn is_binary<P: AsRef<std::path::Path>>(path: P) -> Result<bool> {
29
+ use anyhow::Context;
30
+ let path_ref = path.as_ref();
31
+ let mut file = File::open(path_ref)
32
+ .await
33
+ .with_context(|| format!("Failed to open file {}", path_ref.display()))?;
34
+
35
+ let mut buffer = vec![0u8; 512];
36
+ let bytes_read = file.read(&mut buffer).await?;
37
+ buffer.truncate(bytes_read);
38
+
39
+ Ok(is_binary_internal(&buffer, bytes_read))
40
+ }
41
+
42
+ /// Detects encoding and binary status from a buffer
43
+ fn is_binary_internal(buffer: &[u8], bytes_read: usize) -> bool {
44
+ // Always first check for BOM to find out about encoding
45
+ let encoding = Encoding::detect(buffer, bytes_read);
46
+
47
+ // Detect 0 bytes to see if file is binary or UTF-16 LE/BE
48
+ // unless we already know that this file has a UTF-16 encoding
49
+ let mut seems_binary = false;
50
+ if encoding != Some(Encoding::Utf16BE)
51
+ && encoding != Some(Encoding::Utf16LE)
52
+ && !buffer.is_empty()
53
+ {
54
+ let mut could_be_utf16le = true; // e.g. 0xAA 0x00
55
+ let mut could_be_utf16be = true; // e.g. 0x00 0xAA
56
+ let mut contains_zero_byte = false;
57
+
58
+ // This is a simplified guess to detect UTF-16 BE or LE by just checking if
59
+ // the first 512 bytes have the 0-byte at a specific location. For UTF-16 LE
60
+ // this would be the odd byte index and for UTF-16 BE the even one.
61
+ // Note: this can produce false positives (a binary file that uses a 2-byte
62
+ // encoding of the same format as UTF-16) and false negatives (a UTF-16 file
63
+ // that is using 4 bytes to encode a character).
64
+ const ZERO_BYTE_DETECTION_BUFFER_MAX_LEN: usize = 512;
65
+ for (i, &byte) in buffer
66
+ .iter()
67
+ .enumerate()
68
+ .take(bytes_read.min(ZERO_BYTE_DETECTION_BUFFER_MAX_LEN))
69
+ {
70
+ let is_endian = i % 2 == 1; // assume 2-byte sequences typical for UTF-16
71
+ let is_zero_byte = byte == 0;
72
+
73
+ if is_zero_byte {
74
+ contains_zero_byte = true;
75
+ }
76
+
77
+ // UTF-16 LE: expect e.g. 0xAA 0x00
78
+ if could_be_utf16le && (is_endian && !is_zero_byte || !is_endian && is_zero_byte) {
79
+ could_be_utf16le = false;
80
+ }
81
+
82
+ // UTF-16 BE: expect e.g. 0x00 0xAA
83
+ if could_be_utf16be && (is_endian && is_zero_byte || !is_endian && !is_zero_byte) {
84
+ could_be_utf16be = false;
85
+ }
86
+
87
+ // Return if this is neither UTF16-LE nor UTF16-BE and thus treat as binary
88
+ if is_zero_byte && !could_be_utf16le && !could_be_utf16be {
89
+ break;
90
+ }
91
+ }
92
+
93
+ // Handle case of 0-byte included
94
+ if contains_zero_byte && !could_be_utf16le && !could_be_utf16be {
95
+ seems_binary = true;
96
+ }
97
+ }
98
+
99
+ seems_binary
100
+ }
101
+
102
+ #[cfg(test)]
103
+ mod tests {
104
+ use pretty_assertions::assert_eq;
105
+ use tempfile::NamedTempFile;
106
+ use tokio::fs;
107
+
108
+ use super::*;
109
+
110
+ async fn create_test_file_fixture(content: &[u8]) -> Result<NamedTempFile> {
111
+ let file = NamedTempFile::new()?;
112
+ fs::write(file.path(), content).await?;
113
+ Ok(file)
114
+ }
115
+
116
+ #[tokio::test]
117
+ async fn test_empty_file_is_binary() -> Result<()> {
118
+ let fixture = create_test_file_fixture(&[]).await?;
119
+ let actual = is_binary(fixture.path()).await?;
120
+ let expected = false;
121
+ assert_eq!(actual, expected);
122
+ Ok(())
123
+ }
124
+
125
+ #[tokio::test]
126
+ async fn test_plain_text_file_is_binary() -> Result<()> {
127
+ let fixture = create_test_file_fixture(b"Hello, world!").await?;
128
+ let actual = is_binary(fixture.path()).await?;
129
+ let expected = false;
130
+ assert_eq!(actual, expected);
131
+ Ok(())
132
+ }
133
+
134
+ #[tokio::test]
135
+ async fn test_binary_file_with_zero_bytes() -> Result<()> {
136
+ let content = vec![
137
+ 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x00, 0x57, 0x6F, 0x72, 0x6C, 0x64,
138
+ ];
139
+ let fixture = create_test_file_fixture(&content).await?;
140
+ let actual = is_binary(fixture.path()).await?;
141
+ let expected = true;
142
+ assert_eq!(actual, expected);
143
+ Ok(())
144
+ }
145
+
146
+ #[tokio::test]
147
+ async fn test_buffer_limit_512_bytes() -> Result<()> {
148
+ // Create content larger than 512 bytes with zero byte at position 600
149
+ let mut content = vec![0x48; 600]; // 'H' repeated 600 times
150
+ content[599] = 0x00; // Zero byte beyond 512 byte limit
151
+
152
+ let fixture = create_test_file_fixture(&content).await?;
153
+ let actual = is_binary(fixture.path()).await?;
154
+
155
+ // Should not detect as binary because zero byte is beyond 512-byte limit
156
+ let expected = false;
157
+ assert_eq!(actual, expected);
158
+ Ok(())
159
+ }
160
+ }
crates/forge_fs/src/error.rs ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::string::FromUtf8Error;
2
+
3
+ use thiserror::Error;
4
+
5
+ /// Error type for file operations
6
+ #[derive(Error, Debug)]
7
+ pub enum Error {
8
+ #[error("Binary files are not supported. File detected as {0}")]
9
+ BinaryFileNotSupported(String),
10
+
11
+ #[error("Start position {start} is beyond the file size of {total} characters")]
12
+ StartBeyondFileSize { start: u64, total: u64 },
13
+
14
+ #[error("Start position {start} and end position {end} must be 1-based (inclusive)")]
15
+ IndexStartingWithZero { start: u64, end: u64 },
16
+
17
+ #[error("Start position {start} is greater than end position {end}")]
18
+ StartGreaterThanEnd { start: u64, end: u64 },
19
+
20
+ #[error("UTF-8 validation failed: {0}")]
21
+ Utf8ValidationFailed(#[from] FromUtf8Error),
22
+
23
+ #[error("I/O error: {0}")]
24
+ IoError(#[from] std::io::Error),
25
+ }
crates/forge_fs/src/file_size.rs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::path::Path;
2
+
3
+ use anyhow::{Context, Result};
4
+
5
+ impl crate::ForgeFS {
6
+ /// Gets file size without reading the entire file
7
+ pub async fn file_size<T: AsRef<Path>>(path: T) -> Result<u64> {
8
+ let metadata = tokio::fs::metadata(path.as_ref()).await.with_context(|| {
9
+ format!(
10
+ "Failed to get metadata for file {}",
11
+ path.as_ref().display()
12
+ )
13
+ })?;
14
+
15
+ Ok(metadata.len())
16
+ }
17
+ }
crates/forge_fs/src/is_binary.rs ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use anyhow::Result;
2
+ use tokio::fs::File;
3
+ use tokio::io::AsyncReadExt;
4
+
5
+ impl crate::ForgeFS {
6
+ /// Checks if a file is binary by examining its content.
7
+ /// This version takes a path and opens the file itself.
8
+ #[cfg(test)]
9
+ async fn is_binary_path<T: AsRef<std::path::Path>>(path: T) -> Result<(bool, String)> {
10
+ use anyhow::Context;
11
+
12
+ let path_ref = path.as_ref();
13
+ let mut file = File::open(path_ref)
14
+ .await
15
+ .with_context(|| format!("Failed to open file {}", path_ref.display()))?;
16
+
17
+ Self::is_binary(&mut file).await
18
+ }
19
+
20
+ /// Checks if a file is binary by examining its content.
21
+ /// This version takes an already opened file handle, allowing for reuse
22
+ /// of the same file handle across multiple operations.
23
+ /// This is a crate-private implementation detail.
24
+ pub(crate) async fn is_binary(file: &mut File) -> Result<(bool, String)> {
25
+ // Read sample data
26
+ let mut sample = vec![0; 8192];
27
+ let bytes_read = file.read(&mut sample).await?;
28
+ sample.truncate(bytes_read);
29
+
30
+ // Handle empty files
31
+ if bytes_read == 0 {
32
+ return Ok((true, "Empty file".into()));
33
+ }
34
+
35
+ // Get file type info
36
+ let is_text = match infer::get(&sample) {
37
+ Some(info) => matches!(
38
+ info.matcher_type(),
39
+ infer::MatcherType::Text | infer::MatcherType::Doc
40
+ ),
41
+ None => true, // Assume text if type can't be determined
42
+ };
43
+
44
+ let description = infer::get(&sample)
45
+ .map(|info| info.mime_type().to_string())
46
+ .unwrap_or_else(|| "Text file (no specific format detected)".into());
47
+
48
+ Ok((is_text, description))
49
+ }
50
+ }
51
+
52
+ #[cfg(test)]
53
+ mod test {
54
+ use anyhow::Result;
55
+ use tokio::fs;
56
+
57
+ async fn create_test_file(content: &[u8]) -> Result<tempfile::NamedTempFile> {
58
+ let file = tempfile::NamedTempFile::new()?;
59
+ fs::write(file.path(), content).await?;
60
+ Ok(file)
61
+ }
62
+
63
+ #[tokio::test]
64
+ async fn test_is_binary_file() -> Result<()> {
65
+ // Test text file
66
+ let text_file = create_test_file(b"Hello, world!").await?;
67
+ let (is_text_or_doc, _) = crate::ForgeFS::is_binary_path(text_file.path()).await?;
68
+ assert!(is_text_or_doc, "Text file should be identified as text");
69
+
70
+ // Test binary data
71
+ let binary_content = vec![0, 1, 2, 3, 0, 0, 0, 0, 5, 6, 7, 8];
72
+ let binary_file = create_test_file(&binary_content).await?;
73
+ let (is_text_or_doc, file_type) =
74
+ crate::ForgeFS::is_binary_path(binary_file.path()).await?;
75
+
76
+ if !is_text_or_doc {
77
+ assert!(
78
+ file_type.contains("binary") || !file_type.contains("text"),
79
+ "Binary file type description should indicate binary"
80
+ );
81
+ }
82
+
83
+ // Test PNG file
84
+ let png_header = [
85
+ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
86
+ 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
87
+ 0x00,
88
+ ];
89
+ let png_file = create_test_file(&png_header).await?;
90
+ let (is_text_or_doc, file_type) = crate::ForgeFS::is_binary_path(png_file.path()).await?;
91
+ assert!(!is_text_or_doc, "PNG file should be identified as binary");
92
+ assert!(
93
+ file_type.contains("image/png"),
94
+ "PNG file type should be correctly identified"
95
+ );
96
+
97
+ // Test empty file
98
+ let empty_file = create_test_file(&[]).await?;
99
+ let (is_text_or_doc, _) = crate::ForgeFS::is_binary_path(empty_file.path()).await?;
100
+ assert!(is_text_or_doc, "Empty file should be considered text");
101
+
102
+ Ok(())
103
+ }
104
+ }
crates/forge_fs/src/lib.rs ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! # ForgeFS
2
+ //!
3
+ //! A file system abstraction layer that standardizes error handling for file
4
+ //! operations.
5
+ //!
6
+ //! ForgeFS wraps tokio's filesystem operations with consistent error context
7
+ //! using anyhow::Context. Each method provides standardized error messages in
8
+ //! the format "Failed to [operation] [path]", ensuring uniform error reporting
9
+ //! throughout the application while preserving the original error cause.
10
+
11
+ mod binary_detection;
12
+ mod error;
13
+ mod file_size;
14
+ mod is_binary;
15
+ mod meta;
16
+ mod read;
17
+ mod read_range;
18
+ mod write;
19
+
20
+ pub use crate::binary_detection::is_binary;
21
+ pub use crate::error::Error;
22
+
23
+ /// ForgeFS provides a standardized interface for file system operations
24
+ /// with consistent error handling.
25
+ #[derive(Debug)]
26
+ pub struct ForgeFS;
27
+
28
+ impl ForgeFS {
29
+ /// Computes a SHA-256 hash of the given string content.
30
+ pub(crate) fn compute_hash(content: &str) -> String {
31
+ use sha2::{Digest, Sha256};
32
+ let mut hasher = Sha256::new();
33
+ hasher.update(content.as_bytes());
34
+ hex::encode(hasher.finalize())
35
+ }
36
+ }
crates/forge_fs/src/meta.rs ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::path::Path;
2
+
3
+ use anyhow::{Context, Result};
4
+
5
+ use crate::is_binary;
6
+
7
+ impl crate::ForgeFS {
8
+ pub fn exists<T: AsRef<Path>>(path: T) -> bool {
9
+ path.as_ref().exists()
10
+ }
11
+
12
+ pub async fn is_binary_file<T: AsRef<Path>>(path: T) -> anyhow::Result<bool> {
13
+ is_binary(path).await
14
+ }
15
+
16
+ pub fn is_file<T: AsRef<Path>>(path: T) -> bool {
17
+ path.as_ref().is_file()
18
+ }
19
+
20
+ pub async fn read_dir<T: AsRef<Path>>(path: T) -> Result<tokio::fs::ReadDir> {
21
+ tokio::fs::read_dir(path.as_ref())
22
+ .await
23
+ .with_context(|| format!("Failed to read directory {}", path.as_ref().display()))
24
+ }
25
+ }
crates/forge_fs/src/read.rs ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::path::Path;
2
+
3
+ use anyhow::{Context, Result};
4
+ use bstr::ByteSlice;
5
+
6
+ impl crate::ForgeFS {
7
+ pub async fn read_utf8<T: AsRef<Path>>(path: T) -> Result<String> {
8
+ Self::read(path)
9
+ .await
10
+ .map(|bytes| bytes.to_str_lossy().to_string())
11
+ }
12
+
13
+ pub async fn read<T: AsRef<Path>>(path: T) -> Result<Vec<u8>> {
14
+ tokio::fs::read(path.as_ref())
15
+ .await
16
+ .with_context(|| format!("Failed to read file {}", path.as_ref().display()))
17
+ }
18
+
19
+ pub async fn read_to_string<T: AsRef<Path>>(path: T) -> Result<String> {
20
+ tokio::fs::read_to_string(path.as_ref())
21
+ .await
22
+ .with_context(|| format!("Failed to read file as string {}", path.as_ref().display()))
23
+ }
24
+ }
crates/forge_fs/src/read_range.rs ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::cmp;
2
+ use std::path::Path;
3
+
4
+ use anyhow::{Context, Result};
5
+ use bstr::ByteSlice;
6
+ use forge_domain::FileInfo;
7
+
8
+ use crate::error::Error;
9
+
10
+ impl crate::ForgeFS {
11
+ /// Reads a specific range of lines from a file.
12
+ ///
13
+ /// # Arguments
14
+ /// * `path` - Path to the file to read
15
+ /// * `start_line` - Starting line number (1-based, inclusive)
16
+ /// * `end_line` - Ending line number (1-based, inclusive)
17
+ ///
18
+ /// Returns a tuple containing:
19
+ /// - The range content as a UTF-8 string.
20
+ /// - FileInfo containing metadata about the read operation including line
21
+ /// positions and a `content_hash` computed from the **full** file
22
+ /// content, so callers can store a hash that matches what a subsequent
23
+ /// whole-file read would produce (used by the external-change detector).
24
+ pub async fn read_range_utf8<T: AsRef<Path>>(
25
+ path: T,
26
+ start_line: u64,
27
+ end_line: u64,
28
+ ) -> Result<(String, FileInfo)> {
29
+ let path_ref = path.as_ref();
30
+
31
+ // Basic validation
32
+ if start_line > end_line {
33
+ return Err(Error::StartGreaterThanEnd { start: start_line, end: end_line }.into());
34
+ }
35
+
36
+ // Open and check if file is binary
37
+ let mut file = tokio::fs::File::open(path_ref)
38
+ .await
39
+ .with_context(|| format!("Failed to open file {}", path_ref.display()))?;
40
+
41
+ if start_line == 0 || end_line == 0 {
42
+ return Err(Error::IndexStartingWithZero { start: start_line, end: end_line }.into());
43
+ }
44
+
45
+ let (is_text, file_type) = Self::is_binary(&mut file).await?;
46
+ if !is_text {
47
+ return Err(Error::BinaryFileNotSupported(file_type).into());
48
+ }
49
+
50
+ // Read file content
51
+ let content = tokio::fs::read(path_ref)
52
+ .await
53
+ .with_context(|| format!("Failed to read file content from {}", path_ref.display()))?;
54
+ let content = content.to_str_lossy();
55
+
56
+ // Hash the full file content so callers get a stable, whole-file hash
57
+ // that matches what the external-change detector reads back from disk.
58
+ let content_hash = crate::ForgeFS::compute_hash(content.as_ref());
59
+
60
+ if start_line < 2 && content.is_empty() {
61
+ // If the file is empty, return empty content
62
+ return Ok((
63
+ String::new(),
64
+ FileInfo::new(start_line, end_line, 0, content_hash),
65
+ ));
66
+ }
67
+ // Split into lines
68
+ let lines: Vec<&str> = content.lines().collect();
69
+ let total_lines = lines.len() as u64;
70
+
71
+ // Convert to 0-based indexing
72
+ let start_pos = start_line.saturating_sub(1);
73
+ let mut end_pos = end_line.saturating_sub(1);
74
+
75
+ // Validate start position
76
+ if start_pos >= total_lines {
77
+ return Err(
78
+ Error::StartBeyondFileSize { start: start_line, total: total_lines }.into(),
79
+ );
80
+ }
81
+
82
+ // Cap end position at last line
83
+ end_pos = cmp::min(end_pos, total_lines - 1);
84
+
85
+ // Calculate actual end line (1-based) that was used
86
+ let actual_end_line = cmp::min(end_line, total_lines);
87
+
88
+ let info = FileInfo::new(start_line, actual_end_line, total_lines, content_hash);
89
+
90
+ // Extract requested lines
91
+ let result_content = if start_pos == 0 && end_pos == total_lines - 1 {
92
+ content.to_string() // Return full content if requesting entire file
93
+ } else {
94
+ lines
95
+ .get(start_pos as usize..=end_pos as usize)
96
+ .map(|slice| slice.join("\n"))
97
+ .unwrap_or_default()
98
+ };
99
+
100
+ Ok((result_content, info))
101
+ }
102
+ }
103
+
104
+ #[cfg(test)]
105
+ mod test {
106
+ use anyhow::Result;
107
+ use pretty_assertions::assert_eq;
108
+ use tokio::fs;
109
+
110
+ // Helper to create a temporary file with test content
111
+ async fn create_test_file(content: &str) -> Result<tempfile::NamedTempFile> {
112
+ let file = tempfile::NamedTempFile::new()?;
113
+ fs::write(file.path(), content).await?;
114
+ Ok(file)
115
+ }
116
+
117
+ #[tokio::test]
118
+ async fn test_read_range_utf8() -> Result<()> {
119
+ let content =
120
+ "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10";
121
+ let file = create_test_file(content).await?;
122
+ let full_hash = crate::ForgeFS::compute_hash(content);
123
+
124
+ // Test reading a range of lines
125
+ let (result, info) = crate::ForgeFS::read_range_utf8(file.path(), 2, 5).await?;
126
+ assert_eq!(result, "Line 2\nLine 3\nLine 4\nLine 5");
127
+ assert_eq!(info.start_line, 2);
128
+ assert_eq!(info.end_line, 5);
129
+ assert_eq!(info.total_lines, 10);
130
+ assert_eq!(info.content_hash, full_hash);
131
+
132
+ // Test reading from start
133
+ let (result, info) = crate::ForgeFS::read_range_utf8(file.path(), 1, 3).await?;
134
+ assert_eq!(result, "Line 1\nLine 2\nLine 3");
135
+ assert_eq!(info.start_line, 1);
136
+ assert_eq!(info.end_line, 3);
137
+ assert_eq!(info.content_hash, full_hash);
138
+
139
+ // Test reading to end
140
+ let (result, info) = crate::ForgeFS::read_range_utf8(file.path(), 8, 10).await?;
141
+ assert_eq!(result, "Line 8\nLine 9\nLine 10");
142
+ assert_eq!(info.start_line, 8);
143
+ assert_eq!(info.end_line, 10);
144
+ assert_eq!(info.content_hash, full_hash);
145
+
146
+ // Test reading entire file
147
+ let (result, info) = crate::ForgeFS::read_range_utf8(file.path(), 1, 10).await?;
148
+ assert_eq!(result, content);
149
+ assert_eq!(info.start_line, 1);
150
+ assert_eq!(info.end_line, 10);
151
+ assert_eq!(info.content_hash, full_hash);
152
+
153
+ // Test single line
154
+ let (result, info) = crate::ForgeFS::read_range_utf8(file.path(), 5, 5).await?;
155
+ assert_eq!(result, "Line 5");
156
+ assert_eq!(info.start_line, 5);
157
+ assert_eq!(info.end_line, 5);
158
+ assert_eq!(info.content_hash, full_hash);
159
+
160
+ // Test first line specifically
161
+ let (result, info) = crate::ForgeFS::read_range_utf8(file.path(), 1, 1).await?;
162
+ assert_eq!(result, "Line 1");
163
+ assert_eq!(info.start_line, 1);
164
+ assert_eq!(info.end_line, 1);
165
+ assert_eq!(info.total_lines, 10);
166
+ assert_eq!(info.content_hash, full_hash);
167
+
168
+ // Test invalid ranges
169
+ assert!(
170
+ crate::ForgeFS::read_range_utf8(file.path(), 8, 5)
171
+ .await
172
+ .is_err()
173
+ );
174
+ assert!(
175
+ crate::ForgeFS::read_range_utf8(file.path(), 15, 10)
176
+ .await
177
+ .is_err()
178
+ );
179
+ assert!(
180
+ crate::ForgeFS::read_range_utf8(file.path(), 0, 5)
181
+ .await
182
+ .is_err()
183
+ );
184
+
185
+ Ok(())
186
+ }
187
+
188
+ #[tokio::test]
189
+ async fn test_utf8_multi_line_handling() -> Result<()> {
190
+ let content = "Hello world!\nこんにちは 世界!\nПривет мир!\nBonjour le monde!";
191
+ let file = create_test_file(content).await?;
192
+
193
+ // Test reading a range that includes multi-byte characters
194
+ let (result, info) = crate::ForgeFS::read_range_utf8(file.path(), 2, 3).await?;
195
+ assert_eq!(result, "こんにちは 世界!\nПривет мир!");
196
+ assert_eq!(info.start_line, 2);
197
+ assert_eq!(info.end_line, 3);
198
+
199
+ Ok(())
200
+ }
201
+
202
+ #[tokio::test]
203
+ async fn test_invalid_utf8_handling() -> Result<()> {
204
+ let content = b"Hello world!\nValid line\n\xFF\xFE\xFD Invalid UTF-8\nAnother valid line";
205
+ let file = tempfile::NamedTempFile::new()?;
206
+ fs::write(file.path(), content).await?;
207
+
208
+ // Attempt to read the file shouldn't fail with invalid UTF-8 error
209
+ let result = crate::ForgeFS::read_range_utf8(&file.path(), 1, 4).await;
210
+ assert!(result.is_ok());
211
+ Ok(())
212
+ }
213
+
214
+ #[tokio::test]
215
+ async fn test_end_line_capping() -> Result<()> {
216
+ let content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
217
+ let file = create_test_file(content).await?;
218
+
219
+ // Test: end_line = total_lines (exact match)
220
+ let (actual_content, actual_info) =
221
+ crate::ForgeFS::read_range_utf8(file.path(), 1, 5).await?;
222
+ assert_eq!(actual_content, content);
223
+ assert_eq!(
224
+ (
225
+ actual_info.start_line,
226
+ actual_info.end_line,
227
+ actual_info.total_lines
228
+ ),
229
+ (1, 5, 5)
230
+ );
231
+
232
+ // Test: end_line = total_lines + 1 (one beyond)
233
+ let (actual_content, actual_info) =
234
+ crate::ForgeFS::read_range_utf8(file.path(), 1, 6).await?;
235
+ assert_eq!(actual_content, content);
236
+ assert_eq!(
237
+ (
238
+ actual_info.start_line,
239
+ actual_info.end_line,
240
+ actual_info.total_lines
241
+ ),
242
+ (1, 5, 5)
243
+ );
244
+
245
+ // Test: end_line >> total_lines (far beyond)
246
+ let (actual_content, actual_info) =
247
+ crate::ForgeFS::read_range_utf8(file.path(), 1, 10000).await?;
248
+ assert_eq!(actual_content, content);
249
+ assert_eq!(
250
+ (
251
+ actual_info.start_line,
252
+ actual_info.end_line,
253
+ actual_info.total_lines
254
+ ),
255
+ (1, 5, 5)
256
+ );
257
+
258
+ // Test: range starting in the middle with excessive end_line
259
+ let (actual_content, actual_info) =
260
+ crate::ForgeFS::read_range_utf8(file.path(), 3, 100).await?;
261
+ assert_eq!(actual_content, "Line 3\nLine 4\nLine 5");
262
+ assert_eq!(
263
+ (
264
+ actual_info.start_line,
265
+ actual_info.end_line,
266
+ actual_info.total_lines
267
+ ),
268
+ (3, 5, 5)
269
+ );
270
+
271
+ // Test: reading last line with excessive end_line
272
+ let (actual_content, actual_info) =
273
+ crate::ForgeFS::read_range_utf8(file.path(), 5, 100).await?;
274
+ assert_eq!(actual_content, "Line 5");
275
+ assert_eq!(
276
+ (
277
+ actual_info.start_line,
278
+ actual_info.end_line,
279
+ actual_info.total_lines
280
+ ),
281
+ (5, 5, 5)
282
+ );
283
+
284
+ Ok(())
285
+ }
286
+
287
+ #[tokio::test]
288
+ async fn test_large_file_ranges() -> Result<()> {
289
+ // Create a 5000-line file to test various range scenarios
290
+ let lines: Vec<String> = (1..=5000).map(|i| format!("Line {}", i)).collect();
291
+ let content = lines.join("\n");
292
+ let file = create_test_file(&content).await?;
293
+
294
+ // Test: range within file bounds
295
+ let (actual_content, actual_info) =
296
+ crate::ForgeFS::read_range_utf8(file.path(), 1, 50).await?;
297
+ assert_eq!(actual_content.lines().count(), 50);
298
+ assert_eq!(
299
+ (
300
+ actual_info.start_line,
301
+ actual_info.end_line,
302
+ actual_info.total_lines
303
+ ),
304
+ (1, 50, 5000)
305
+ );
306
+
307
+ // Test: range in the middle
308
+ let (actual_content, actual_info) =
309
+ crate::ForgeFS::read_range_utf8(file.path(), 2500, 3500).await?;
310
+ assert_eq!(actual_content.lines().count(), 1001); // Lines 2500-3500 inclusive
311
+ assert_eq!(
312
+ (
313
+ actual_info.start_line,
314
+ actual_info.end_line,
315
+ actual_info.total_lines
316
+ ),
317
+ (2500, 3500, 5000)
318
+ );
319
+
320
+ // Test: end beyond file bounds (simulates max_read_size limiting)
321
+ let (actual_content, actual_info) =
322
+ crate::ForgeFS::read_range_utf8(file.path(), 4990, 6000).await?;
323
+ assert_eq!(actual_content.lines().count(), 11); // Lines 4990-5000
324
+ assert_eq!(
325
+ (
326
+ actual_info.start_line,
327
+ actual_info.end_line,
328
+ actual_info.total_lines
329
+ ),
330
+ (4990, 5000, 5000)
331
+ );
332
+
333
+ Ok(())
334
+ }
335
+ }