Skip to content

Commit 0464725

Browse files
authored
Implement session struct (#40)
1 parent e159e1d commit 0464725

3 files changed

Lines changed: 242 additions & 33 deletions

File tree

src/api/mod.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ pub fn get_chat_client_implementation(
7878
system_prompt: String,
7979
max_tokens: usize,
8080
) -> Box<dyn ChatClient> {
81+
// Test-only provider that returns a lightweight stub client without external dependencies
82+
#[cfg(test)]
83+
{
84+
if provider.eq_ignore_ascii_case("test") {
85+
return Box::new(TestChatClient::new(system_prompt, max_tokens));
86+
}
87+
}
8188
match provider.to_lowercase().as_str() {
8289
"anthropic" => Box::new(AnthropicClient::new(
8390
model.to_string(),
@@ -92,3 +99,57 @@ pub fn get_chat_client_implementation(
9299
_ => panic!("Unsupported provider"),
93100
}
94101
}
102+
103+
#[cfg(test)]
104+
struct TestChatClient {
105+
system_prompt: String,
106+
max_tokens: usize,
107+
}
108+
109+
#[cfg(test)]
110+
impl TestChatClient {
111+
fn new(system_prompt: String, max_tokens: usize) -> Self {
112+
Self {
113+
system_prompt,
114+
max_tokens,
115+
}
116+
}
117+
}
118+
119+
#[cfg(test)]
120+
impl ChatClient for TestChatClient {
121+
fn generate_response(
122+
&self,
123+
_history_messages_json: Value,
124+
_user_prompt: &str,
125+
_context_content: Option<&str>,
126+
) -> io::Result<ChatResponse> {
127+
Ok(ChatResponse {
128+
content: String::from("test-response"),
129+
tool_calls: None,
130+
})
131+
}
132+
133+
fn generate_tool_response(&self, _tool_prompt: Value) -> io::Result<ChatResponse> {
134+
Ok(ChatResponse {
135+
content: String::from("test-tool-response"),
136+
tool_calls: None,
137+
})
138+
}
139+
140+
fn model_context_size(&self) -> Option<usize> {
141+
Some(self.max_tokens)
142+
}
143+
144+
fn model_supports_tools(&self) -> bool {
145+
false
146+
}
147+
148+
fn update_system_prompt(&mut self, system_prompt: String) {
149+
self.system_prompt = system_prompt;
150+
}
151+
152+
fn system_prompt(&self) -> String {
153+
self.system_prompt.clone()
154+
}
155+
}

src/main.rs

Lines changed: 19 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ mod models;
2121
pub mod tool;
2222
pub mod traits;
2323
mod user_input;
24+
mod session;
2425

2526
#[cfg(test)]
2627
mod test_support;
2728

28-
use crate::api::{ChatClient, get_chat_client_implementation};
29-
use crate::command::commands::{CommandResult, create_command_registry};
30-
use crate::config::{AppConfig, args::Args};
29+
use crate::command::commands::{create_command_registry, CommandResult};
30+
use crate::config::{args::Args, AppConfig};
3131
use crate::models::context_file::ContextFile;
32-
use crate::models::history_file::HistoryFile;
32+
use crate::session::Session;
3333
use crate::traits::estimate_context_size::ContextEstimation;
3434
use clap::Parser;
3535
use colored::Colorize;
@@ -38,55 +38,41 @@ use std::io::{self};
3838

3939
fn main() -> io::Result<()> {
4040
let args = Args::parse();
41-
let mut app_config = AppConfig::load_config(&args.history_file);
41+
let app_config = AppConfig::load_config(&args.history_file);
4242
let command_registry = create_command_registry(app_config.user_config.command_prefixes.clone());
4343
let mut context_file_path = args.context_file.clone();
44-
let mut history = HistoryFile::new(
45-
app_config.cache_config.get_history_file_path(),
46-
app_config.data_dir.display().to_string(),
47-
)?;
48-
49-
history.print_content();
50-
app_config.print_model_info();
51-
52-
let mut chat_client: Box<dyn ChatClient> = get_chat_client_implementation(
53-
&app_config.current_profile.provider,
54-
&app_config.current_model.model,
55-
app_config.user_config.system_prompt.clone(),
56-
app_config.user_config.max_tokens,
57-
);
44+
let mut session = Session::new(app_config);
45+
46+
session.history_file.print_content();
47+
session.config.print_model_info();
48+
5849
let mut rebuild_chat_client = false;
5950

6051
loop {
6152
if rebuild_chat_client {
62-
chat_client = get_chat_client_implementation(
63-
&app_config.current_profile.provider,
64-
&app_config.current_model.model,
65-
app_config.user_config.system_prompt.clone(),
66-
app_config.user_config.max_tokens,
67-
);
53+
session.rebuild_client();
6854
rebuild_chat_client = false;
6955
}
7056

7157
// TODO: This shouldn't be printed on every iteration and model information should be fetched once
72-
if &app_config.current_profile.provider == "ollama" && chat_client.model_supports_tools() {
58+
if session.config.current_profile.provider == "ollama" && session.client.model_supports_tools() {
7359
println!("Model supports tools");
7460
}
7561

7662
let context_file = ContextFile::new(&context_file_path);
7763

78-
if app_config.user_config.token_estimation {
64+
if session.config.user_config.token_estimation {
7965
print_token_usage(
80-
history.estimate_context_size() + context_file.estimate_context_size(),
81-
&chat_client.model_context_size(),
66+
session.history_file.estimate_context_size() + context_file.estimate_context_size(),
67+
&session.client.model_context_size(),
8268
);
8369
}
8470

8571
println!(
8672
"\nEnter your prompt or a command (type ':q' to end or ':help' for other command)"
8773
);
8874

89-
let mut rl = match app_config.create_rustyline_editor(&command_registry) {
75+
let mut rl = match session.config.create_rustyline_editor(&command_registry) {
9076
Ok(r) => r,
9177
Err(e) => {
9278
eprintln!("Error initializing rustyline: {e}");
@@ -103,9 +89,9 @@ fn main() -> io::Result<()> {
10389
};
10490

10591
let mut processor = CommandProcessor::new(
106-
&mut chat_client,
107-
&mut history,
108-
&mut app_config,
92+
&mut session.client,
93+
&mut session.history_file,
94+
&mut session.config,
10995
&command_registry,
11096
&mut context_file_path,
11197
&mut rebuild_chat_client,

src/session.rs

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
* Copyright © 2025 Mitja Leino
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
5+
* documentation files (the “Software”), to deal in the Software without restriction, including without limitation
6+
* the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
7+
* and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
8+
*
9+
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
10+
*
11+
* THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
12+
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
13+
* OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
14+
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
15+
*/
16+
use crate::api::get_chat_client_implementation;
17+
use crate::config::AppConfig;
18+
use crate::models::history_file::HistoryFile;
19+
20+
pub(crate) struct Session {
21+
pub(crate) config: AppConfig,
22+
pub(crate) client: Box<dyn crate::api::ChatClient>,
23+
pub(crate) history_file: HistoryFile,
24+
}
25+
26+
impl Session {
27+
pub(crate) fn new(config: AppConfig) -> Self {
28+
let client = Self::create_client(&config);
29+
let history_file = HistoryFile::new(
30+
config.cache_config.get_history_file_path(),
31+
config.data_dir.display().to_string(),
32+
).expect("Failed to determine history file");
33+
34+
Self {
35+
config,
36+
client,
37+
history_file,
38+
}
39+
}
40+
41+
fn create_client(config: &AppConfig) -> Box<dyn crate::api::ChatClient> {
42+
get_chat_client_implementation(
43+
&config.current_profile.provider,
44+
&config.current_model.model,
45+
config.user_config.system_prompt.clone(),
46+
config.user_config.max_tokens,
47+
)
48+
}
49+
50+
pub(crate) fn rebuild_client(&mut self) {
51+
self.client = Self::create_client(&self.config);
52+
}
53+
54+
pub fn update_config<F>(&mut self, modifier: F)
55+
where
56+
F: FnOnce(&mut AppConfig) -> bool,
57+
{
58+
let should_rebuild = modifier(&mut self.config);
59+
if should_rebuild {
60+
self.rebuild_client();
61+
}
62+
}
63+
}
64+
65+
#[cfg(test)]
66+
mod tests {
67+
use super::Session;
68+
use crate::config::profiles_config::{Model, ModelType, Profile};
69+
use crate::config::{AppConfig, CacheConfig, UserConfig};
70+
use tempfile::TempDir;
71+
72+
fn make_test_config(temp: &TempDir, history_name: &str, system_prompt: &str) -> AppConfig {
73+
let mut config = AppConfig::default();
74+
75+
// point data_dir to a temporary location to avoid touching user directories
76+
let chats_dir = temp.path().join("chats");
77+
std::fs::create_dir_all(&chats_dir).unwrap();
78+
config.data_dir = chats_dir;
79+
80+
// minimal cache config so HistoryFile path is resolvable
81+
config.cache_config = CacheConfig {
82+
last_history_file: Some(history_name.to_string()),
83+
last_profile_name: Some("test-profile".to_string()),
84+
profile_models: None,
85+
};
86+
87+
// minimal user config
88+
let mut user = UserConfig::default();
89+
user.system_prompt = system_prompt.to_string();
90+
config.user_config = user.clone();
91+
92+
// set a test profile that maps to our test stub ChatClient
93+
config.current_profile = Profile {
94+
name: "test-profile".to_string(),
95+
provider: "test".to_string(),
96+
models: vec![Model {
97+
model: "test-model".to_string(),
98+
description: Some("test model".to_string()),
99+
model_type: ModelType::Fast,
100+
}],
101+
};
102+
103+
config.current_model = Model {
104+
model: "test-model".to_string(),
105+
description: Some("test model".to_string()),
106+
model_type: ModelType::Fast,
107+
};
108+
109+
config
110+
}
111+
112+
#[test]
113+
fn session_new_initializes_history_file() {
114+
let temp = TempDir::new().unwrap();
115+
let config = make_test_config(&temp, "sess.hist", "sp-1");
116+
117+
let session = Session::new(config);
118+
119+
// History file filename should match
120+
assert_eq!(session.history_file.filename, "sess.hist");
121+
// File should exist on disk
122+
assert!(std::path::Path::new(&session.history_file.path).exists());
123+
}
124+
125+
#[test]
126+
fn update_config_without_rebuild_keeps_client() {
127+
let temp = TempDir::new().unwrap();
128+
let mut session = Session::new(make_test_config(&temp, "sess2.hist", "sp-initial"));
129+
130+
// Changing the system prompt but returning false should not rebuild client
131+
session.update_config(|c| {
132+
c.user_config.system_prompt = "sp-updated".to_string();
133+
false
134+
});
135+
136+
assert_eq!(session.client.system_prompt(), "sp-initial".to_string());
137+
}
138+
139+
#[test]
140+
fn update_config_with_rebuild_updates_client() {
141+
let temp = TempDir::new().unwrap();
142+
let mut session = Session::new(make_test_config(&temp, "sess3.hist", "sp-a"));
143+
144+
session.update_config(|c| {
145+
c.user_config.system_prompt = "sp-b".to_string();
146+
true // request rebuild
147+
});
148+
149+
assert_eq!(session.client.system_prompt(), "sp-b".to_string());
150+
}
151+
152+
#[test]
153+
fn rebuild_client_applies_current_config() {
154+
let temp = TempDir::new().unwrap();
155+
let mut session = Session::new(make_test_config(&temp, "sess4.hist", "sp-0"));
156+
157+
session.config.user_config.system_prompt = "sp-1".to_string();
158+
session.rebuild_client();
159+
160+
assert_eq!(session.client.system_prompt(), "sp-1".to_string());
161+
}
162+
}

0 commit comments

Comments
 (0)