Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- crates/forge_domain/src/chat_request.rs +17 -0
- crates/forge_domain/src/command.rs +21 -0
- crates/forge_domain/src/console.rs +15 -0
- crates/forge_domain/src/conversation.rs +358 -0
- crates/forge_domain/src/conversation_html.rs +513 -0
- crates/forge_domain/src/conversation_style.css +313 -0
- crates/forge_domain/src/data_gen.rs +28 -0
- crates/forge_domain/src/env.rs +388 -0
- crates/forge_domain/src/error.rs +178 -0
- crates/forge_domain/src/event.rs +227 -0
- crates/forge_domain/src/file_operation.rs +25 -0
- crates/forge_domain/src/group_by_key.rs +28 -0
- crates/forge_domain/src/hook.rs +1136 -0
- crates/forge_domain/src/http_config.rs +284 -0
- crates/forge_domain/src/image.rs +30 -0
- crates/forge_domain/src/lib.rs +119 -0
- crates/forge_domain/src/max_tokens.rs +199 -0
- crates/forge_domain/src/mcp_servers.rs +50 -0
- crates/forge_domain/src/merge.rs +59 -0
- crates/forge_domain/src/message.rs +471 -0
- crates/forge_domain/src/message_pattern.rs +262 -0
- crates/forge_domain/src/model_config.rs +30 -0
- crates/forge_domain/src/node.rs +506 -0
- crates/forge_domain/src/provider.rs +1013 -0
- crates/forge_domain/src/shell.rs +14 -0
- crates/forge_domain/src/skill.rs +102 -0
- crates/forge_domain/src/snapshot.rs +148 -0
- crates/forge_domain/src/suggestion.rs +7 -0
- crates/forge_domain/src/temperature.rs +215 -0
- crates/forge_domain/src/tool_order.rs +436 -0
- crates/forge_domain/src/top_k.rs +194 -0
- crates/forge_domain/src/update.rs +34 -0
- crates/forge_domain/src/validation.rs +10 -0
- crates/forge_domain/src/workspace.rs +30 -0
- crates/forge_domain/src/xml.rs +168 -0
- crates/forge_domain/tests/test_stringified_tool_calls.rs +313 -0
- crates/forge_eventsource_stream/src/event.rs +16 -0
- crates/forge_eventsource_stream/src/event_stream.rs +560 -0
- crates/forge_eventsource_stream/src/lib.rs +41 -0
- crates/forge_eventsource_stream/src/parser.rs +124 -0
- crates/forge_eventsource_stream/src/traits.rs +19 -0
- crates/forge_eventsource_stream/src/utf8_stream.rs +175 -0
- crates/forge_fs/src/binary_detection.rs +160 -0
- crates/forge_fs/src/error.rs +25 -0
- crates/forge_fs/src/file_size.rs +17 -0
- crates/forge_fs/src/is_binary.rs +104 -0
- crates/forge_fs/src/lib.rs +36 -0
- crates/forge_fs/src/meta.rs +25 -0
- crates/forge_fs/src/read.rs +24 -0
- 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 |
+
}
|