diff --git a/Cargo.lock b/Cargo.lock index 01dc199..3fa025b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,12 +52,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "autocfg" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - [[package]] name = "bitflags" version = "2.9.1" @@ -131,72 +125,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" -[[package]] -name = "convert_case" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "derive_more" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "document-features" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" -dependencies = [ - "litrs", -] - [[package]] name = "endian-type" version = "0.1.2" @@ -251,7 +179,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi", ] [[package]] @@ -303,22 +231,6 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" -[[package]] -name = "litrs" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" - -[[package]] -name = "lock_api" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" -dependencies = [ - "autocfg", - "scopeguard", -] - [[package]] name = "log" version = "0.4.27" @@ -331,18 +243,6 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" -[[package]] -name = "mio" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" -dependencies = [ - "libc", - "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys", -] - [[package]] name = "nibble_vec" version = "0.1.0" @@ -376,29 +276,6 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" -[[package]] -name = "parking_lot" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets", -] - [[package]] name = "proc-macro2" version = "1.0.95" @@ -433,15 +310,6 @@ dependencies = [ "nibble_vec", ] -[[package]] -name = "redox_syscall" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" -dependencies = [ - "bitflags", -] - [[package]] name = "rustix" version = "1.0.7" @@ -477,12 +345,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "serde" version = "1.0.219" @@ -512,42 +374,11 @@ dependencies = [ "serde", ] -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" -dependencies = [ - "libc", -] - [[package]] name = "sllama" -version = "0.1.7" +version = "0.1.8" dependencies = [ "clap", - "crossterm", "rustyline", "serde", "tempfile", @@ -655,12 +486,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - [[package]] name = "wasi" version = "0.14.2+wasi-0.2.4" @@ -670,28 +495,6 @@ dependencies = [ "wit-bindgen-rt", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index fff0231..f644137 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,10 @@ [package] name = "sllama" -version = "0.1.7" +version = "0.1.8" edition = "2024" [dependencies] clap = { version = "4.5.39", features = ["derive"] } -crossterm = { version = "0.29.0" } serde = { version = "1.0.219", features = ["derive"] } toml = "0.8.23" tempfile = "3.20.0" diff --git a/LICENSES/crossterm-MIT b/LICENSES/crossterm-MIT deleted file mode 100644 index 778c041..0000000 --- a/LICENSES/crossterm-MIT +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2019 Timon - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/changelog.md b/changelog.md index c68d79f..ff4a6bc 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,9 @@ # Changelog +## 0.1.8 - 2025-06-09 + +_Refactor command handling and remove crossterm_ + ## 0.1.7 - 2025-06-08 _Add `edit` command_ diff --git a/readme.md b/readme.md index 008aa0d..86b25c4 100644 --- a/readme.md +++ b/readme.md @@ -6,6 +6,7 @@ A command-line interface for interacting with Ollama AI models. - Store conversations as files - Add context to a session with `-f/--file` flag +- Use commands to modify and customize the current session - Prompts are built in the following way: 1. System prompt 2. Context file @@ -133,8 +134,10 @@ You can tell where you have previously responded by --- AI Response --- (added a - [x] Clarify how the prompt is formed - [x] Add a configuration file - [x] Integrate rustyline +- [ ] Implement completions with rustyline (commands and files) +- [ ] Support multiline input with shift + enter (using rustyline) - [ ] Allow changing the context file during a chat -- [ ] Implement completions with rustyline +- [ ] Add support for knowledge directory - [ ] Re-implement AI response interruption - [ ] Add functionality to truncate a chat - [ ] Keep track of the model's context window and file size @@ -146,7 +149,6 @@ You can tell where you have previously responded by --- AI Response --- (added a - [serde](https://github.com/serde-rs/serde) - [MIT](LICENSES/serde-MIT) - [toml](https://github.com/toml-rs/toml) - [MIT](LICENSES/toml-MIT) - [clap](https://github.com/clap-rs/clap) - [MIT](LICENSES/clap-MIT) -- [crossterm](https://github.com/crossterm-rs/crossterm) - [MIT](LICENSES/crossterm-MIT) - [tempfile](https://github.com/Stebalien/tempfile) - [MIT](LICENSES/tempfile-MIT) - [rustyline](https://github.com/kkawakam/rustyline) - [MIT](LICENSES/rustyline-MIT) diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..a4ebe62 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,343 @@ +/* + * Copyright © 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the “Software”), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS + * OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +use crate::history_file::HistoryFile; +use crate::ollama_client::OllamaClient; +use std::collections::HashMap; +use std::process::Command; +use std::{env, fs, io}; + +pub enum CommandResult { + Continue, + Quit, + SwitchHistory(String), +} + +pub struct CommandParams<'a, 'b> { + args: &'a [&'b str], + ollama_client: &'a mut OllamaClient, + history: &'a mut HistoryFile, + sllama_dir: &'a str, +} + +impl<'a, 'b> CommandParams<'a, 'b> { + pub fn new( + args: &'a [&'b str], + ollama_client: &'a mut OllamaClient, + history: &'a mut HistoryFile, + sllama_dir: &'a str, + ) -> Self { + CommandParams { + args, + ollama_client, + history, + sllama_dir, + } + } +} + +type CommandFn = fn(CommandParams) -> io::Result; + +pub fn create_command_registry() -> HashMap<&'static str, CommandFn> { + let mut commands: HashMap<&'static str, CommandFn> = HashMap::new(); + + commands.insert(":q", quit_command); + commands.insert(":list", list_command); + commands.insert(":switch", switch_command); + commands.insert(":sysprompt", sysprompt_command); + commands.insert(":help", help_command); + commands.insert(":edit", edit_command); + + commands +} + +fn quit_command(command_params: CommandParams) -> io::Result { + println!( + "Ending conversation. All interactions saved to '{}'", + command_params.history.filename + ); + Ok(CommandResult::Quit) +} + +fn list_command(command_params: CommandParams) -> io::Result { + let pattern = command_params.args.get(0).unwrap_or(&""); + + fn list_dir_contents(dir: &str, pattern: &str, sllama_dir: &str) -> io::Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if (pattern.is_empty() || path.display().to_string().contains(pattern)) + && !path.is_dir() + { + match path.display().to_string().strip_prefix(sllama_dir) { + None => println!("{}", path.display()), + Some(ds) => { + let mut cleaned_ds = ds.to_string(); + if cleaned_ds.starts_with('/') { + cleaned_ds = cleaned_ds[1..].to_string(); + } + println!("{}", cleaned_ds) + } + } + } + if path.is_dir() { + list_dir_contents(path.to_str().unwrap(), pattern, sllama_dir)?; + } + } + Ok(()) + } + + list_dir_contents( + command_params.sllama_dir, + pattern, + command_params.sllama_dir, + )?; + Ok(CommandResult::Continue) +} + +fn help_command(_command_params: CommandParams) -> io::Result { + println!("\nAvailable commands:"); + println!(":q - quit"); + println!( + ":list - list files in the sllama directory. \ + Optionally, you can provide a pattern to filter the results." + ); + println!( + ":switch - switch to a different history file. \ + Either relative to sllama_dir or absolute path. Creates the file if it doesn't exist." + ); + println!(":help - show this help message"); + println!(":edit - open the history file in your editor"); + println!(":sysprompt - set the system prompt for current session"); + Ok(CommandResult::Continue) +} + +fn switch_command(command_params: CommandParams) -> io::Result { + let new_history_file = command_params.args.get(0).unwrap_or(&""); + + if new_history_file.is_empty() { + println!("Error: No history file specified. Usage: :switch "); + return Ok(CommandResult::Continue); + } + + Ok(CommandResult::SwitchHistory(new_history_file.to_string())) +} + +fn edit_command(command_params: CommandParams) -> io::Result { + let history = command_params.history; + let editor = env::var("EDITOR") + .or_else(|_| env::var("VISUAL")) + .unwrap_or_else(|_| { + if cfg!(target_os = "windows") { + "notepad".to_string() + } else { + "vi".to_string() + } + }); + + let status = Command::new(editor).arg(history.path.clone()).status(); + if !status.map_or(false, |s| s.success()) { + eprintln!("Error opening file in editor"); + } + history.reload_content(); + + Ok(CommandResult::Continue) +} + +fn sysprompt_command(command_params: CommandParams) -> io::Result { + command_params + .ollama_client + .update_system_prompt(command_params.args.join(" ")); + Ok(CommandResult::Continue) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + /// Helper function to create the test environment + fn setup_test_environment() -> (OllamaClient, HistoryFile, TempDir, String) { + let temp_dir = TempDir::new().unwrap(); + let dir_path = temp_dir.path().to_str().unwrap().to_string(); + + let ollama_client = OllamaClient::new("test-model".to_string(), "test-prompt".to_string()); + + // Create a temporary history file with some content + let history_path = format!("{}/test-history.txt", dir_path); + fs::write(&history_path, "Test conversation content").unwrap(); + + let history = HistoryFile::new("test-history.txt".to_string(), dir_path.clone()).unwrap(); + + (ollama_client, history, temp_dir, dir_path) + } + + #[test] + fn test_list_command() -> io::Result<()> { + let (mut ollama_client, mut history, _temp_dir, dir_path) = setup_test_environment(); + + // Create a few test history files + fs::write(format!("{}/history1.txt", dir_path), "Content 1")?; + fs::write(format!("{}/history2.txt", dir_path), "Content 2")?; + + let args: Vec<&str> = vec![]; + let params = CommandParams::new(&args, &mut ollama_client, &mut history, &dir_path); + + let result = list_command(params)?; + assert!(matches!(result, CommandResult::Continue)); + + // We can't easily test the stdout output here without mocking, + // but the command should run without errors + + Ok(()) + } + + #[test] + fn test_switch_command() -> io::Result<()> { + let (mut ollama_client, mut history, _temp_dir, dir_path) = setup_test_environment(); + + // Create a test history file to switch to + let new_history_file = "new-history.txt"; + fs::write( + format!("{}/{}", dir_path, new_history_file), + "New history content", + )?; + + let args = vec![new_history_file]; + let params = CommandParams::new(&args, &mut ollama_client, &mut history, &dir_path); + + let result = switch_command(params)?; + + if let CommandResult::SwitchHistory(filename) = result { + assert_eq!(filename, new_history_file); + } else { + panic!("Expected SwitchHistory result but got something else"); + } + + Ok(()) + } + + #[test] + fn test_help_command() -> io::Result<()> { + let (mut ollama_client, mut history, _temp_dir, dir_path) = setup_test_environment(); + + let args: Vec<&str> = vec![]; + let params = CommandParams::new(&args, &mut ollama_client, &mut history, &dir_path); + + let result = help_command(params)?; + assert!(matches!(result, CommandResult::Continue)); + + Ok(()) + } + + #[test] + fn test_exit_command() -> io::Result<()> { + let (mut ollama_client, mut history, _temp_dir, dir_path) = setup_test_environment(); + + let args: Vec<&str> = vec![]; + let params = CommandParams::new(&args, &mut ollama_client, &mut history, &dir_path); + + let result = quit_command(params)?; + assert!(matches!(result, CommandResult::Quit)); + + Ok(()) + } + + #[test] + fn test_edit_command() -> io::Result<()> { + let (mut ollama_client, mut history, _temp_dir, dir_path) = setup_test_environment(); + + // We'll mock the editor by setting it to "echo" which should exist on most systems + // and will just return successfully without doing anything + unsafe { + env::set_var("EDITOR", "echo"); + } + + let args: Vec<&str> = vec![]; + let params = CommandParams::new(&args, &mut ollama_client, &mut history, &dir_path); + + let result = edit_command(params)?; + assert!(matches!(result, CommandResult::Continue)); + + Ok(()) + } + + #[test] + fn test_sysprompt_command() -> io::Result<()> { + let (mut ollama_client, mut history, _temp_dir, dir_path) = setup_test_environment(); + + let test_prompt = "This is a test system prompt"; + let args: Vec<&str> = test_prompt.split_whitespace().collect(); + let params = CommandParams::new(&args, &mut ollama_client, &mut history, &dir_path); + + let result = sysprompt_command(params)?; + assert!(matches!(result, CommandResult::Continue)); + + // Verify the prompt was updated + assert_eq!(ollama_client.system_prompt, test_prompt); + + Ok(()) + } + + #[test] + fn test_create_command_registry() { + let registry = create_command_registry(); + + // Check that all expected commands are registered + assert!(registry.contains_key(":q")); + assert!(registry.contains_key(":list")); + assert!(registry.contains_key(":switch")); + assert!(registry.contains_key(":sysprompt")); + assert!(registry.contains_key(":help")); + assert!(registry.contains_key(":edit")); + + // Check the total number of commands + assert_eq!(registry.len(), 6); + } + + #[test] + fn test_switch_command_with_no_args() -> io::Result<()> { + let (mut ollama_client, mut history, _temp_dir, dir_path) = setup_test_environment(); + + let args: Vec<&str> = vec![]; + let params = CommandParams::new(&args, &mut ollama_client, &mut history, &dir_path); + + let result = switch_command(params)?; + assert!(matches!(result, CommandResult::Continue)); + + Ok(()) + } + + #[test] + fn test_list_command_with_pattern() -> io::Result<()> { + let (mut ollama_client, mut history, _temp_dir, dir_path) = setup_test_environment(); + + // Create some test files + fs::write(format!("{}/history1.txt", dir_path), "Content 1")?; + fs::write(format!("{}/history2.txt", dir_path), "Content 2")?; + fs::write(format!("{}/other.txt", dir_path), "Other content")?; + + // Test with a pattern that should match some files + let args = vec!["history"]; + let params = CommandParams::new(&args, &mut ollama_client, &mut history, &dir_path); + + let result = list_command(params)?; + assert!(matches!(result, CommandResult::Continue)); + + Ok(()) + } +} diff --git a/src/config.rs b/src/config.rs index 4a1cbf0..428703a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,20 @@ +/* + * Copyright © 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the “Software”), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS + * OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + use serde::Deserialize; use std::path::PathBuf; use std::{fs, io}; @@ -56,7 +73,7 @@ impl Config { let config_str = match fs::read_to_string(&config_path) { Ok(s) => s, Err(s) => { - println!("Could not read config file: {}", s); + eprintln!("Could not read config file: {}", s); return Ok(Config::default()); } }; @@ -82,3 +99,21 @@ fn get_home_dir() -> Result { return Err("Could not determine home directory"); } } + +#[cfg(test)] +mod tests { + use crate::config::Config; + + #[test] + fn test_default_config_values() { + let config = Config::default(); + + // Check default values directly + assert_eq!(config.model, "gemma3:12b"); + assert!(config.system_prompt.contains("You are an AI assistant")); + + // For sllama_dir, just check that it ends with "/sllama" or "\sllama" + // rather than testing the specific home directory path + assert!(config.sllama_dir.ends_with("/sllama") || config.sllama_dir.ends_with("\\sllama")); + } +} diff --git a/src/history_file.rs b/src/history_file.rs index cc1acb5..442d14e 100644 --- a/src/history_file.rs +++ b/src/history_file.rs @@ -1,10 +1,29 @@ +/* + * Copyright © 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the “Software”), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS + * OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + use std::fs::OpenOptions; use std::io; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +#[derive(Debug)] pub(crate) struct HistoryFile { pub(crate) path: String, + pub(crate) filename: String, content: String, } @@ -24,6 +43,12 @@ impl HistoryFile { actual_path }; + let filename = full_path + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(); + if let Some(parent) = full_path.parent() { std::fs::create_dir_all(parent)?; } @@ -43,6 +68,7 @@ impl HistoryFile { Ok(HistoryFile { path: path_string, content, + filename, }) } @@ -70,18 +96,13 @@ impl HistoryFile { pub(crate) fn append_ai_response( &mut self, response: &str, - was_interrupted: bool, ) -> io::Result<()> { let mut file = OpenOptions::new() .write(true) .append(true) .open(&self.path)?; - let response_with_note = if was_interrupted { - format!("{}\n\n[Response was interrupted by user]", response) - } else { - response.to_string() - }; + let response_with_note = response.to_string(); let entry = format!("\n\n--- AI Response ---\n\n{}", response_with_note); file.write_all(entry.as_bytes())?; @@ -126,12 +147,19 @@ mod tests { fn test_new_creates_file_if_not_exists() { let temp_path = NamedTempFile::new().unwrap(); let path = temp_path.path().to_str().unwrap().to_string(); + let expected_filename = temp_path + .path() + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(); temp_path.close().unwrap(); // Delete the file let history_file = HistoryFile::new(path.clone(), String::new()).unwrap(); assert!(fs::metadata(&path).is_ok()); // File exists assert_eq!(history_file.get_content(), ""); // Empty content + assert_eq!(history_file.filename, expected_filename); // Filename is extracted correctly } #[test] @@ -139,10 +167,17 @@ mod tests { let content = "Existing content"; let temp_file = create_temp_file_with_content(content); let path = temp_file.path().to_str().unwrap().to_string(); + let expected_filename = temp_file + .path() + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(); let history_file = HistoryFile::new(path, String::new()).unwrap(); assert_eq!(history_file.get_content(), content); + assert_eq!(history_file.filename, expected_filename); } #[test] @@ -172,7 +207,7 @@ mod tests { let mut history_file = HistoryFile::new(path.clone(), String::new()).unwrap(); history_file.append_user_input("User message 1").unwrap(); history_file - .append_ai_response("AI response 1", false) + .append_ai_response("AI response 1") .unwrap(); history_file.append_user_input("User message 2").unwrap(); @@ -194,7 +229,7 @@ mod tests { let mut history_file = HistoryFile::new(path.clone(), String::new()).unwrap(); history_file - .append_ai_response("AI response", false) + .append_ai_response("AI response") .unwrap(); // Verify internal content was updated @@ -208,28 +243,6 @@ mod tests { assert_eq!(file_content, "\n\n--- AI Response ---\n\nAI response"); } - #[test] - fn test_append_ai_response_interrupted() { - let temp_file = NamedTempFile::new().unwrap(); - let path = temp_file.path().to_str().unwrap().to_string(); - - let mut history_file = HistoryFile::new(path.clone(), String::new()).unwrap(); - history_file - .append_ai_response("AI response", true) - .unwrap(); - - // Verify internal content was updated with interruption note - assert!( - history_file - .get_content() - .contains("[Response was interrupted by user]") - ); - - // Verify file content was updated with interruption note - let file_content = fs::read_to_string(path).unwrap(); - assert!(file_content.contains("[Response was interrupted by user]")); - } - #[test] fn test_newline_handling() { let temp_file = create_temp_file_with_content("Initial content"); @@ -274,6 +287,9 @@ mod tests { history_file.path, expected_path.to_string_lossy().to_string() ); + + // Verify the filename is extracted correctly + assert_eq!(history_file.filename, "test_history.txt"); } #[test] @@ -298,5 +314,26 @@ mod tests { // Verify the path stored in the HistoryFile is the absolute path assert_eq!(history_file.path, absolute_path); + + // Verify the filename is extracted correctly + assert_eq!(history_file.filename, "absolute_history.txt"); + } + + #[test] + fn test_directory_path_handling() { + // Create a temporary directory + let temp_dir = tempfile::tempdir().unwrap(); + let dir_path = temp_dir.path().to_string_lossy().to_string(); + + // Attempt to create a history file with a directory path + let result = HistoryFile::new(dir_path, String::new()); + + // Should result in an error, not a panic + assert!(result.is_err()); + + // Just check that we get an error, without asserting on the specific error kind + // since it can vary between operating systems + let _error = result.unwrap_err(); + println!("Got expected error when opening directory: {:?}", _error); } } diff --git a/src/main.rs b/src/main.rs index c8b7f94..115cd72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,35 @@ +/* + * Copyright © 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the “Software”), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS + * OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +mod commands; mod config; mod history_file; mod ollama_client; use config::Config; -use std::env; +use crate::commands::CommandResult::SwitchHistory; +use crate::commands::{create_command_registry, CommandParams}; use crate::history_file::HistoryFile; use crate::ollama_client::OllamaClient; use clap::Parser; use std::fs::{self}; use std::io::{self}; use std::path::PathBuf; -use std::process::Command; #[derive(Parser)] #[command(author, version, about, long_about = None)] @@ -35,9 +53,7 @@ fn main() -> io::Result<()> { // Parse command-line arguments let args = Args::parse(); - - // Get the filename from arguments - let filename = &args.history_file; + let command_registry = create_command_registry(); // Read the input file if provided let input_file_content = if let Some(file_path) = args.input_file { @@ -55,7 +71,8 @@ fn main() -> io::Result<()> { None }; - let mut history = HistoryFile::new(filename.clone(), config.sllama_dir.clone())?; + // Get the filename from arguments + let mut history = HistoryFile::new(args.history_file.clone(), config.sllama_dir.clone())?; println!("{}", history.get_content()); println!("You're conversing with {} model", &config.model); let mut ollama_client = OllamaClient::new(config.model, config.system_prompt); @@ -83,82 +100,39 @@ fn main() -> io::Result<()> { } }; - // Check if user wants to exit - let user_prompt = user_prompt.trim().to_lowercase(); + let user_prompt = user_prompt.trim(); if user_prompt.starts_with(":") { let parts: Vec<&str> = user_prompt.split_whitespace().collect(); let command_string = parts[0].to_lowercase(); let args: Vec<&str> = parts[1..].to_vec(); - match command_string.as_str() { - ":q" => { - println!( - "Ending conversation. All interactions saved to '{}'", - filename - ); - break; - } - ":list" => { - list_command(&config.sllama_dir, args); - continue; - } - ":switch" => { - if let Some(new_history_file) = switch_command(args) { - let filename = new_history_file; - history = HistoryFile::new(filename.clone(), config.sllama_dir.clone())?; - println!("{}", history.get_content()); - println!("Switched to history file: {}", filename); - } - continue; - } - ":sysprompt" => { - ollama_client.update_system_prompt(args.join(" ")); - continue; - } - ":help" => { - println!("\nAvailable commands:"); - println!(":q - quit"); - println!( - ":list - list files in the sllama directory. \ - Optionally, you can provide a pattern to filter the results." - ); - println!( - ":switch - switch to a different history file. \ - Either relative to sllama_dir or absolute path." - ); - println!(":help - show this help message"); - println!(":edit - open the history file in your editor"); - println!(":sysprompt - set the system prompt for current session"); - continue; - } - ":edit" => { - let editor = env::var("EDITOR") - .or_else(|_| env::var("VISUAL")) - .unwrap_or_else(|_| { - if cfg!(target_os = "windows") { - "notepad".to_string() - } else { - "vi".to_string() - } - }); - - let status = Command::new(editor).arg(history.path.clone()).status()?; - - if !status.success() { - println!("Error opening file in editor"); + if user_prompt.starts_with(":") { + let command_params = CommandParams::new( + &*args, + &mut ollama_client, + &mut history, + &config.sllama_dir, + ); + + if let Some(command_fn) = command_registry.get(command_string.as_str()) { + match command_fn(command_params)? { + commands::CommandResult::Quit => break, + SwitchHistory(new_file) => { + history = HistoryFile::new(new_file, config.sllama_dir.clone())?; + println!("{}", history.get_content()); + println!("Switched to history file: {}", history.filename); + continue; + } + commands::CommandResult::Continue => continue, } - - history.reload_content(); - continue; - } - _ => { - println!("Unknown command '{}'", command_string); + } else { + println!("Unknown command: {}", command_string); continue; } } } - let (ollama_response, was_interrupted) = ollama_client.generate_response( + let ollama_response = ollama_client.generate_response( history.get_content(), &user_prompt, input_file_content.as_deref(), @@ -166,54 +140,8 @@ fn main() -> io::Result<()> { history.append_user_input(&user_prompt)?; - history.append_ai_response(&ollama_response, was_interrupted)?; + history.append_ai_response(&ollama_response)?; } Ok(()) } - -fn list_command(sllama_dir: &str, args: Vec<&str>) { - let pattern = args.get(0).unwrap_or(&""); - - fn list_dir_contents(dir: &str, pattern: &str, sllama_dir: &str) -> io::Result<()> { - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - - if (pattern.is_empty() || path.display().to_string().contains(pattern)) - && !path.is_dir() - { - match path.display().to_string().strip_prefix(sllama_dir) { - None => println!("{}", path.display()), - Some(ds) => { - let mut cleaned_ds = ds.to_string(); - if cleaned_ds.starts_with('/') { - cleaned_ds = cleaned_ds[1..].to_string(); - } - println!("{}", cleaned_ds) - } - } - } - if path.is_dir() { - list_dir_contents(path.to_str().unwrap(), pattern, sllama_dir)?; - } - } - Ok(()) - } - - match list_dir_contents(sllama_dir, pattern, sllama_dir) { - Ok(_) => (), - Err(e) => eprintln!("Error reading directory: {}", e), - } -} - -fn switch_command(args: Vec<&str>) -> Option { - let new_history_file = args.get(0).unwrap_or(&""); - - if new_history_file.is_empty() { - println!("Error: No history file specified. Usage: :switch "); - return None; - } - - Some(new_history_file.to_string()) -} diff --git a/src/ollama_client.rs b/src/ollama_client.rs index e681698..d9c7037 100644 --- a/src/ollama_client.rs +++ b/src/ollama_client.rs @@ -1,13 +1,28 @@ +/* + * Copyright © 2025 Mitja Leino + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the “Software”), to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS + * OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + use std::io::{BufReader, Read, Write}; -use std::process::{Child, Command, Stdio}; -use std::sync::mpsc; -use std::sync::mpsc::{Receiver, TryRecvError}; +use std::process::{Command, Stdio}; use std::time::Duration; use std::{io, thread}; pub(crate) struct OllamaClient { model: String, - system_prompt: String, + pub(crate) system_prompt: String, } impl OllamaClient { @@ -23,7 +38,7 @@ impl OllamaClient { history_content: &str, user_prompt: &str, context_content: Option<&str>, - ) -> io::Result<(String, bool)> { + ) -> io::Result { // Create the ollama command with stdout piped let mut cmd = Command::new("ollama") .args(&["run", &self.model]) @@ -53,35 +68,13 @@ impl OllamaClient { stdin.write_all(user_prompt.as_bytes())?; } - // Get stdout stream from the child process let stdout = cmd.stdout.take().expect("Failed to open stdout"); let mut reader = BufReader::new(stdout); - - // Set up channel for interrupt signal - let (interrupt_tx, interrupt_rx) = mpsc::channel(); - - // thread::spawn(move || { - // println!("\nAI is responding... (Press Enter to interrupt)\n"); - // loop { - // if event::poll(Duration::from_millis(100)).unwrap() { - // if let Event::Key(key) = event::read().unwrap() { - // if key.code == KeyCode::Enter { - // let _ = interrupt_tx.send(()); - // break; - // } - // } - // } - // } - // }); - - // Read response while checking for interrupt - let (full_response, was_interrupted) = - read_process_output_with_interrupt(&mut reader, &interrupt_rx, &mut cmd) - .expect("error reading process output"); - + let full_response = + read_process_output_with_interrupt(&mut reader).expect("error reading process output"); let ollama_response = String::from_utf8_lossy(&full_response).to_string(); - Ok((ollama_response, was_interrupted)) + Ok(ollama_response) } pub(crate) fn update_system_prompt(&mut self, new_system_prompt: String) { @@ -89,30 +82,11 @@ impl OllamaClient { } } -fn read_process_output_with_interrupt( - reader: &mut BufReader, - interrupt_rx: &Receiver<()>, - cmd: &mut Child, -) -> io::Result<(Vec, bool)> { +fn read_process_output_with_interrupt(reader: &mut BufReader) -> io::Result> { let mut buffer = [0; 1024]; let mut full_response = Vec::new(); - let mut was_interrupted = false; loop { - // Check for interrupt signal - match interrupt_rx.try_recv() { - Ok(_) | Err(TryRecvError::Disconnected) => { - // Interrupt signal received, kill the process - println!("\n[Interrupting AI response...]"); - cmd.kill()?; - was_interrupted = true; - break; - } - Err(TryRecvError::Empty) => { - // No interrupt, continue reading - } - } - // Set up non-blocking read with timeout match reader.read(&mut buffer) { Ok(0) => break, // End of stream @@ -136,20 +110,7 @@ fn read_process_output_with_interrupt( thread::sleep(Duration::from_millis(10)); } - // Try to read any remaining output if we were interrupted - if was_interrupted { - loop { - match reader.read(&mut buffer) { - Ok(0) => break, - Ok(bytes_read) => { - full_response.extend_from_slice(&buffer[..bytes_read]); - } - Err(_) => break, - } - } - } - - Ok((full_response, was_interrupted)) + Ok(full_response) } #[cfg(test)]