mirror of
https://github.com/taggart-comet/quill-code.git
synced 2026-07-09 16:08:35 +00:00
init2
This commit is contained in:
parent
edf99cb06a
commit
d570ca5444
54 changed files with 6704 additions and 69 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/target
|
||||
/.idea
|
||||
/.venv
|
||||
venv/
|
||||
/.claude
|
||||
/models/
|
||||
./models/
|
||||
1486
Cargo.lock
generated
Normal file
1486
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
46
Cargo.toml
Normal file
46
Cargo.toml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
[package]
|
||||
name = "drastis"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "drastis"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "drastis"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
llama-cpp-2 = "0.1"
|
||||
num_cpus = "1.16"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rustyline = "15"
|
||||
directories = "5"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
tree-sitter = "0.24"
|
||||
tree-sitter-rust = "0.23"
|
||||
tree-sitter-python = "0.23"
|
||||
tree-sitter-javascript = "0.23"
|
||||
tree-sitter-typescript = "0.23"
|
||||
tree-sitter-go = "0.23"
|
||||
tree-sitter-java = "0.23"
|
||||
tree-sitter-c = "0.23"
|
||||
tree-sitter-cpp = "0.23"
|
||||
tree-sitter-ruby = "0.23"
|
||||
tree-sitter-json = "0.24"
|
||||
tree-sitter-toml-ng = "0.7"
|
||||
tree-sitter-html = "0.23"
|
||||
tree-sitter-css = "0.23"
|
||||
tree-sitter-bash = "0.23"
|
||||
tree-sitter-md = "0.3"
|
||||
regex = "1.10"
|
||||
thiserror = "1.0"
|
||||
ctrlc = "3.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.0"
|
||||
BIN
cats/__pycache__/cat.cpython-314.pyc
Normal file
BIN
cats/__pycache__/cat.cpython-314.pyc
Normal file
Binary file not shown.
24
cats/cat.py
Normal file
24
cats/cat.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
class Cat:
|
||||
def __init__(self, name: str, age: int):
|
||||
self.name = name
|
||||
self.age = age
|
||||
self.mood = "neutral"
|
||||
|
||||
def meow(self) -> str:
|
||||
return f"{self.name} says: Meow!"
|
||||
|
||||
def feed(self) -> str:
|
||||
self.mood = "happy"
|
||||
return f"{self.name} is eating. Nom nom nom!"
|
||||
|
||||
def pet(self) -> str:
|
||||
if self.mood == "happy":
|
||||
return f"{self.name} purrs loudly."
|
||||
return f"{self.name} tolerates the petting."
|
||||
|
||||
def describe(self) -> str:
|
||||
return f"{self.name} is a {self.age} year old cat feeling {self.mood}."
|
||||
|
||||
|
||||
def create_cat(name: str, age: int) -> Cat:
|
||||
return Cat(name, age)
|
||||
48
cats/test_cat.py
Normal file
48
cats/test_cat.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import pytest
|
||||
from cat import Cat, create_cat
|
||||
|
||||
|
||||
def test_cat_creation():
|
||||
cat = Cat("Whiskers", 3)
|
||||
assert cat.name == "Whiskers"
|
||||
assert cat.age == 3
|
||||
assert cat.mood == "neutral"
|
||||
|
||||
|
||||
def test_meow():
|
||||
cat = Cat("Luna", 2)
|
||||
assert cat.meow() == "Luna says: Meow!"
|
||||
|
||||
|
||||
def test_feed_changes_mood():
|
||||
cat = Cat("Oliver", 5)
|
||||
assert cat.mood == "neutral"
|
||||
cat.feed()
|
||||
assert cat.mood == "happy"
|
||||
|
||||
|
||||
def test_pet_when_neutral():
|
||||
cat = Cat("Milo", 1)
|
||||
result = cat.pet()
|
||||
assert "tolerates" in result
|
||||
|
||||
|
||||
def test_pet_when_happy():
|
||||
cat = Cat("Bella", 4)
|
||||
cat.feed()
|
||||
result = cat.pet()
|
||||
assert "purrs" in result
|
||||
|
||||
|
||||
def test_describe():
|
||||
cat = Cat("Max", 7)
|
||||
desc = cat.describe()
|
||||
assert "Max" in desc
|
||||
assert "7" in desc
|
||||
assert "neutral" in desc
|
||||
|
||||
|
||||
def test_create_cat_helper():
|
||||
cat = create_cat("Simba", 2)
|
||||
assert isinstance(cat, Cat)
|
||||
assert cat.name == "Simba"
|
||||
12
src/domain/mod.rs
Normal file
12
src/domain/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
pub mod prompting;
|
||||
pub mod tools;
|
||||
pub mod workflow;
|
||||
pub mod session;
|
||||
pub mod startup;
|
||||
mod project;
|
||||
|
||||
pub use project::Project;
|
||||
pub use session::{Session, SessionRequest};
|
||||
pub use session::service::SessionService;
|
||||
pub use startup::{StartupService, StartupConfig};
|
||||
pub use workflow::{Workflow, Chain, CancellationToken};
|
||||
40
src/domain/project.rs
Normal file
40
src/domain/project.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
use crate::repository::ProjectRow;
|
||||
|
||||
/// Domain entity representing a project.
|
||||
/// A project groups related sessions together, typically scoped to a directory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Project {
|
||||
id: i64,
|
||||
name: String,
|
||||
created_at: u64,
|
||||
session_count: u64,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn id(&self) -> i64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> u64 {
|
||||
self.created_at
|
||||
}
|
||||
|
||||
pub fn session_count(&self) -> u64 {
|
||||
self.session_count
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProjectRow> for Project {
|
||||
fn from(row: ProjectRow) -> Self {
|
||||
Self {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
created_at: row.created_at.parse().unwrap_or(0),
|
||||
session_count: row.session_count as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src/domain/prompting/general.rs
Normal file
106
src/domain/prompting/general.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
use crate::domain::workflow::Chain;
|
||||
use crate::domain::workflow::Toolset;
|
||||
|
||||
/// LLM prompt templates for the coding assistant
|
||||
///
|
||||
/// This module contains all prompt templates used for LLM interactions.
|
||||
|
||||
/// Create a prompt for the LLM to choose the next tool
|
||||
pub fn tool_selection_prompt(
|
||||
user_prompt: &str,
|
||||
toolset: &Toolset,
|
||||
chain: &Chain,
|
||||
) -> String {
|
||||
format!(
|
||||
"<|im_start|>system\nYou are a coding assistant. Choose ONE next tool to use from the available tools to accomplish the user's request.\n\n{}\n\n<|im_end|>\n\
|
||||
<|im_start|>user\n{}---\n{}\n<|im_end|>\n\
|
||||
<output_format>\nrespond in a valid yaml format of the chosen tool, specifying input values for the tool, according to it's interface\n<\\output_format>\n\
|
||||
<|im_start|>assistant\n",
|
||||
toolset.get_tools_description(),
|
||||
_format_chain_context(chain),
|
||||
user_prompt,
|
||||
)
|
||||
}
|
||||
|
||||
fn _format_chain_context(chain: &Chain) -> String {
|
||||
use crate::domain::workflow::step::StepType;
|
||||
|
||||
let mut context = String::new();
|
||||
context.push_str("previous_tool_calls:\n");
|
||||
|
||||
// should be in yaml format like:
|
||||
// previous_tool_calls:
|
||||
// - tool_name: name
|
||||
// execution_order: 1 # autoincremented
|
||||
// status: successful/error
|
||||
// input: Yaml
|
||||
// output: Yaml
|
||||
let mut execution_order = 0;
|
||||
for step in chain.steps() {
|
||||
// Only include tool_call steps, skip interruptions and other step types
|
||||
if step.step_type != StepType::ToolCall.as_str() {
|
||||
continue;
|
||||
}
|
||||
|
||||
execution_order += 1;
|
||||
|
||||
// Extract tool_name from summary (format: "Tool `name` was executed successfully" or "Tool `name` failed: error")
|
||||
let tool_name = if let Some(start) = step.summary.find('`') {
|
||||
if let Some(end) = step.summary[start + 1..].find('`') {
|
||||
&step.summary[start + 1..start + 1 + end]
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
// Determine status from summary
|
||||
let status = if step.summary.contains("successfully") {
|
||||
"successful"
|
||||
} else if step.summary.contains("failed") {
|
||||
"error"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
// Format as YAML
|
||||
context.push_str(&format!("- tool_name: {}\n", tool_name));
|
||||
context.push_str(&format!(" execution_order: {}\n", execution_order));
|
||||
context.push_str(&format!(" status: {}\n", status));
|
||||
|
||||
// Format input as YAML (handle multi-line with proper indentation)
|
||||
let input_str = step.input_payload.trim();
|
||||
if input_str.is_empty() {
|
||||
context.push_str(" input: null\n");
|
||||
} else if input_str.contains('\n') || input_str.starts_with('{') || input_str.starts_with('[') {
|
||||
// Multi-line or complex YAML - use literal block scalar
|
||||
context.push_str(" input: |\n");
|
||||
for line in input_str.lines() {
|
||||
context.push_str(&format!(" {}\n", line));
|
||||
}
|
||||
} else {
|
||||
// Single line - can be inline
|
||||
context.push_str(&format!(" input: {}\n", input_str));
|
||||
}
|
||||
|
||||
// Format output as YAML (handle multi-line with proper indentation)
|
||||
let output_str = step.context_payload.trim();
|
||||
if output_str.is_empty() {
|
||||
context.push_str(" output: null\n");
|
||||
} else if output_str.contains('\n') || output_str.starts_with('{') || output_str.starts_with('[') {
|
||||
// Multi-line or complex YAML - use literal block scalar
|
||||
context.push_str(" output: |\n");
|
||||
for line in output_str.lines() {
|
||||
context.push_str(&format!(" {}\n", line));
|
||||
}
|
||||
} else {
|
||||
// Single line - can be inline
|
||||
context.push_str(&format!(" output: {}\n", output_str));
|
||||
}
|
||||
}
|
||||
|
||||
context
|
||||
}
|
||||
|
||||
|
||||
7
src/domain/prompting/mod.rs
Normal file
7
src/domain/prompting/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod general;
|
||||
mod session;
|
||||
mod tools;
|
||||
|
||||
pub use general::*;
|
||||
pub use session::*;
|
||||
pub use tools::*;
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
use crate::domain::Session;
|
||||
|
||||
/// Format session history and current request as a complete prompt
|
||||
pub fn format_session_prompt(session: &Session) -> String {
|
||||
let mut formatted = String::new();
|
||||
|
||||
// Add history section
|
||||
formatted.push_str("previous requests history:\n");
|
||||
|
||||
if session.requests().is_empty() {
|
||||
formatted.push_str(" (no previous requests)\n");
|
||||
} else {
|
||||
for request in session.requests() {
|
||||
formatted.push_str(&format!(" - request: {}\n", request.prompt()));
|
||||
if let Some(result) = request.result_summary() {
|
||||
formatted.push_str(&format!(" result: {}\n", result));
|
||||
} else {
|
||||
formatted.push_str(" result: (no result yet)\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add current request
|
||||
formatted.push_str(&format!("CURRENT REQUEST: {}", session.current_request()));
|
||||
|
||||
formatted
|
||||
}
|
||||
|
||||
/// Create a prompt for generating a short session name
|
||||
pub fn session_naming_prompt(prompt_preview: &str) -> String {
|
||||
format!(
|
||||
"<|im_start|>system\nYou generate very short titles (3-5 words). Respond with only the title.<|im_end|>\n\
|
||||
<|im_start|>user\nTitle for: {}<|im_end|>\n\
|
||||
<|im_start|>assistant\n",
|
||||
prompt_preview
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
use crate::domain::Chain;
|
||||
use crate::domain::tools::Tool;
|
||||
use crate::domain::workflow::Toolset;
|
||||
|
||||
/// Format available tools as a description for the LLM
|
||||
///
|
||||
/// Generates a formatted list of all available tools with their descriptions
|
||||
/// and input formats
|
||||
pub fn format_tools_description<'a>(tools: impl Iterator<Item = (&'a str, &'a dyn Tool)>) -> String {
|
||||
let mut description = String::from("Available Tools:\n\n");
|
||||
|
||||
for (name, tool) in tools {
|
||||
description.push_str(&format!("Description: {}\n", tool.desc()));
|
||||
description.push_str("Format:\n");
|
||||
description.push_str(&format!("```yaml\ntool_name: {}{}```\n\n", name, tool.input_format()));
|
||||
}
|
||||
description
|
||||
}
|
||||
|
||||
7
src/domain/session/mod.rs
Normal file
7
src/domain/session/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub mod session;
|
||||
pub mod session_request;
|
||||
pub mod service;
|
||||
|
||||
pub use session::Session;
|
||||
pub use session_request::SessionRequest;
|
||||
pub use service::{SessionService, ServiceError};
|
||||
98
src/domain/session/service.rs
Normal file
98
src/domain/session/service.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
use crate::domain::workflow::{Workflow, Chain, CancellationToken, Error as WorkflowError};
|
||||
use super::session::Session;
|
||||
use crate::infrastructure::inference::InferenceEngine;
|
||||
use crate::repository::SessionRequestsRepository;
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Service for running workflows on sessions
|
||||
pub struct SessionService {
|
||||
workflow: Workflow,
|
||||
}
|
||||
|
||||
impl SessionService {
|
||||
/// Create a new session service with default workflow
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
workflow: Workflow::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a session service with a custom workflow
|
||||
pub fn with_workflow(workflow: Workflow) -> Self {
|
||||
Self { workflow }
|
||||
}
|
||||
|
||||
/// Run the workflow for a new request in the given session
|
||||
/// This is the main entry point for executing a coding task
|
||||
/// Creates a new session_request, runs the workflow, and updates the request with the result
|
||||
/// Returns the execution chain
|
||||
pub fn run(
|
||||
&self,
|
||||
session: &Session,
|
||||
prompt: &str,
|
||||
engine: &InferenceEngine,
|
||||
cancel: &CancellationToken,
|
||||
conn: &Connection,
|
||||
) -> Result<Chain, ServiceError> {
|
||||
// Create a new session request
|
||||
let requests_repo = SessionRequestsRepository::new(conn);
|
||||
let request_row = requests_repo.create(session.id(), prompt)
|
||||
.map_err(|e| ServiceError::Repository(e))?;
|
||||
let request_id = request_row.id;
|
||||
|
||||
// Update session with current request for workflow execution
|
||||
// We need a mutable session, but we only have a reference
|
||||
// The workflow uses session.current_prompt() which needs the current_request set
|
||||
// For now, we'll create a temporary session with the current request set
|
||||
let mut session_with_request = session.clone();
|
||||
session_with_request.set_current_request(prompt.to_string());
|
||||
|
||||
// Run the workflow
|
||||
let result = match self.workflow.run(&session_with_request, engine, cancel) {
|
||||
Ok(chain) => {
|
||||
// Get summary and log from chain
|
||||
let summary = chain.get_summary();
|
||||
let steps_log = chain.get_log();
|
||||
|
||||
// Update the request with result_summary and steps_log
|
||||
requests_repo.update_result(request_id, &summary)
|
||||
.map_err(|e| ServiceError::Repository(e))?;
|
||||
requests_repo.update_steps_log(request_id, &steps_log)
|
||||
.map_err(|e| ServiceError::Repository(e))?;
|
||||
|
||||
Ok(chain)
|
||||
}
|
||||
Err(e) => {
|
||||
// Create a chain with error
|
||||
let mut chain = Chain::new();
|
||||
chain.mark_failed(format!("Error: {}", e));
|
||||
|
||||
let summary = chain.get_summary();
|
||||
let steps_log = chain.get_log();
|
||||
|
||||
requests_repo.update_result(request_id, &summary)
|
||||
.map_err(|e| ServiceError::Repository(e))?;
|
||||
requests_repo.update_steps_log(request_id, &steps_log)
|
||||
.map_err(|e| ServiceError::Repository(e))?;
|
||||
|
||||
Err(ServiceError::Workflow(e))
|
||||
}
|
||||
};
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ServiceError {
|
||||
#[error("workflow error: {0}")]
|
||||
Workflow(WorkflowError),
|
||||
#[error("repository error: {0}")]
|
||||
Repository(String),
|
||||
}
|
||||
|
||||
impl Default for SessionService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
74
src/domain/session/session.rs
Normal file
74
src/domain/session/session.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
use super::session_request::SessionRequest;
|
||||
use crate::repository::SessionRow;
|
||||
|
||||
/// Domain entity representing a conversation session.
|
||||
/// A session belongs to a project and contains a named conversation history.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
id: i64,
|
||||
project_id: i64,
|
||||
name: String,
|
||||
created_at: u64,
|
||||
requests: Vec<SessionRequest>,
|
||||
current_request: String,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn id(&self) -> i64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn project_id(&self) -> i64 {
|
||||
self.project_id
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> u64 {
|
||||
self.created_at
|
||||
}
|
||||
|
||||
pub fn requests(&self) -> &[SessionRequest] {
|
||||
&self.requests
|
||||
}
|
||||
|
||||
pub fn add_request(&mut self, request: SessionRequest) {
|
||||
self.requests.push(request);
|
||||
}
|
||||
|
||||
pub fn set_current_request(&mut self, prompt: String) {
|
||||
self.current_request = prompt;
|
||||
}
|
||||
|
||||
pub fn current_request(&self) -> &str {
|
||||
&self.current_request
|
||||
}
|
||||
|
||||
pub fn set_requests(&mut self, requests: Vec<SessionRequest>) {
|
||||
self.requests = requests;
|
||||
}
|
||||
|
||||
pub fn load_with_requests(
|
||||
session_row: crate::repository::SessionRow,
|
||||
requests: Vec<SessionRequest>
|
||||
) -> Self {
|
||||
let mut session = Self::from(session_row);
|
||||
session.set_requests(requests);
|
||||
session
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionRow> for Session {
|
||||
fn from(row: SessionRow) -> Self {
|
||||
Self {
|
||||
id: row.id,
|
||||
project_id: row.project_id,
|
||||
name: row.name,
|
||||
created_at: row.created_at.parse().unwrap_or(0),
|
||||
requests: Vec::new(),
|
||||
current_request: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/domain/session/session_request.rs
Normal file
64
src/domain/session/session_request.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
use crate::repository::SessionRequestRow;
|
||||
|
||||
/// Domain entity representing a user request within a session.
|
||||
/// Each request contains the user's prompt and the resulting summary.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRequest {
|
||||
id: i64,
|
||||
session_id: i64,
|
||||
prompt: String,
|
||||
result_summary: Option<String>,
|
||||
created_at: u64,
|
||||
}
|
||||
|
||||
impl SessionRequest {
|
||||
pub fn new(session_id: i64, prompt: String) -> Self {
|
||||
Self {
|
||||
id: 0, // Will be set when saved to database
|
||||
session_id,
|
||||
prompt,
|
||||
result_summary: None,
|
||||
created_at: 0, // Will be set when saved to database
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> i64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn session_id(&self) -> i64 {
|
||||
self.session_id
|
||||
}
|
||||
|
||||
pub fn prompt(&self) -> &str {
|
||||
&self.prompt
|
||||
}
|
||||
|
||||
pub fn result_summary(&self) -> Option<&str> {
|
||||
self.result_summary.as_deref()
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> u64 {
|
||||
self.created_at
|
||||
}
|
||||
|
||||
pub fn set_result_summary(&mut self, summary: String) {
|
||||
self.result_summary = Some(summary);
|
||||
}
|
||||
|
||||
pub fn has_result(&self) -> bool {
|
||||
self.result_summary.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionRequestRow> for SessionRequest {
|
||||
fn from(row: SessionRequestRow) -> Self {
|
||||
Self {
|
||||
id: row.id,
|
||||
session_id: row.session_id,
|
||||
prompt: row.prompt,
|
||||
result_summary: row.result_summary,
|
||||
created_at: row.created_at.parse().unwrap_or(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
mod service;
|
||||
|
||||
pub use service::{StartupService, StartupConfig};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("repository error: {0}")]
|
||||
Repository(String),
|
||||
|
||||
#[error("session not found: {0}")]
|
||||
SessionNotFound(i64),
|
||||
}
|
||||
|
||||
impl From<Error> for String {
|
||||
fn from(err: Error) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
use super::Error;
|
||||
use crate::domain::prompting::session_naming_prompt;
|
||||
use crate::domain::{Project, Session, SessionRequest};
|
||||
use crate::infrastructure::inference::InferenceEngine;
|
||||
use crate::repository::{ProjectsRepository, SessionsRepository, SessionRequestsRepository};
|
||||
use rusqlite::Connection;
|
||||
use std::env;
|
||||
|
||||
/// Configuration for the startup service
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StartupConfig {
|
||||
pub debug: bool,
|
||||
}
|
||||
|
||||
/// Domain service responsible for creating sessions and managing domain logic
|
||||
/// This service operates on already-initialized infrastructure components
|
||||
pub struct StartupService {
|
||||
config: StartupConfig,
|
||||
}
|
||||
|
||||
impl StartupService {
|
||||
/// Create a new startup service with the given configuration
|
||||
pub fn new(config: StartupConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Create a startup service with debug configuration
|
||||
pub fn with_debug(debug: bool) -> Self {
|
||||
Self::new(StartupConfig { debug })
|
||||
}
|
||||
|
||||
/// Create a new session with the given first prompt
|
||||
/// This is a pure domain operation that uses infrastructure components
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `conn` - Database connection (infrastructure)
|
||||
/// * `engine` - Inference engine (infrastructure)
|
||||
/// * `first_prompt` - The initial user prompt
|
||||
///
|
||||
/// # Returns
|
||||
/// A ready Session entity with the first request and conversation history
|
||||
pub fn start(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
engine: &InferenceEngine,
|
||||
first_prompt: &str,
|
||||
) -> Result<Session, Error> {
|
||||
// 1. Get or create project based on current directory name
|
||||
let project = self.init_project(conn)?;
|
||||
|
||||
// 2. Create session with the first prompt
|
||||
self.create_session(conn, engine, &project, first_prompt)
|
||||
}
|
||||
|
||||
/// Initialize or load a project based on the current directory
|
||||
fn init_project(&self, conn: &Connection) -> Result<Project, Error> {
|
||||
let project_name = env::current_dir()
|
||||
.ok()
|
||||
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
let repo = ProjectsRepository::new(conn);
|
||||
let (row, created) = repo.get_or_create(&project_name)
|
||||
.map_err(Error::Repository)?;
|
||||
|
||||
// Map repository row to domain entity
|
||||
let project = Project::from(row);
|
||||
|
||||
if created {
|
||||
log::info!("Project created: {} (id={})", project.name(), project.id());
|
||||
} else {
|
||||
log::info!(
|
||||
"Project loaded: {} (id={}, sessions={})",
|
||||
project.name(),
|
||||
project.id(),
|
||||
project.session_count()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
/// Create a new session with the given first prompt
|
||||
fn create_session(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
engine: &InferenceEngine,
|
||||
project: &Project,
|
||||
first_prompt: &str,
|
||||
) -> Result<Session, Error> {
|
||||
// Generate session name from the first prompt using chat format
|
||||
let prompt_preview: String = first_prompt.chars().take(100).collect();
|
||||
let naming_prompt = session_naming_prompt(&prompt_preview);
|
||||
|
||||
let session_name = match engine.generate_silent(&naming_prompt, 15) {
|
||||
Ok(raw) => {
|
||||
log::debug!("Raw session name response: {:?}", raw);
|
||||
// Clean up the response
|
||||
let cleaned = raw
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.trim_matches('*')
|
||||
.replace("<|im_end|>", "")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if cleaned.is_empty() || cleaned.len() > 50 {
|
||||
Self::fallback_session_name(first_prompt)
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Session naming failed: {}", e);
|
||||
Self::fallback_session_name(first_prompt)
|
||||
}
|
||||
};
|
||||
|
||||
// Create session in database
|
||||
let sessions_repo = SessionsRepository::new(conn);
|
||||
let row = sessions_repo.create(project.id(), &session_name)
|
||||
.map_err(Error::Repository)?;
|
||||
|
||||
// Increment project session count
|
||||
let projects_repo = ProjectsRepository::new(conn);
|
||||
projects_repo.increment_session_count(project.id())
|
||||
.map_err(Error::Repository)?;
|
||||
|
||||
// Create session without requests (requests will be created by session_service.run())
|
||||
let session = Session::from(row);
|
||||
|
||||
log::info!("Session created: \"{}\" (id={})", session.name(), session.id());
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Generate a fallback session name from the prompt
|
||||
fn fallback_session_name(prompt: &str) -> String {
|
||||
// Use first few words of the prompt as fallback
|
||||
let words: Vec<&str> = prompt.split_whitespace().take(5).collect();
|
||||
let name = words.join(" ");
|
||||
if name.len() > 40 {
|
||||
format!("{}...", &name[..37])
|
||||
} else if name.is_empty() {
|
||||
"New Session".to_string()
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
/// Load an existing session by ID with all its requests
|
||||
pub fn load_session(&self, conn: &Connection, session_id: i64) -> Result<Session, Error> {
|
||||
let sessions_repo = SessionsRepository::new(conn);
|
||||
let session_row = sessions_repo
|
||||
.find_by_id(session_id)
|
||||
.map_err(Error::Repository)?
|
||||
.ok_or(Error::SessionNotFound(session_id))?;
|
||||
|
||||
let requests_repo = SessionRequestsRepository::new(conn);
|
||||
let request_rows = requests_repo.find_by_session(session_id)
|
||||
.map_err(Error::Repository)?;
|
||||
let requests: Vec<SessionRequest> = request_rows
|
||||
.into_iter()
|
||||
.map(SessionRequest::from)
|
||||
.collect();
|
||||
|
||||
Ok(Session::load_with_requests(session_row, requests))
|
||||
}
|
||||
|
||||
/// Add a new request to an existing session
|
||||
pub fn add_request(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
session: &mut Session,
|
||||
prompt: &str,
|
||||
) -> Result<(), Error> {
|
||||
let requests_repo = SessionRequestsRepository::new(conn);
|
||||
let request_row = requests_repo.create(session.id(), prompt)
|
||||
.map_err(Error::Repository)?;
|
||||
let request = SessionRequest::from(request_row);
|
||||
session.add_request(request);
|
||||
session.set_current_request(prompt.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the result of the latest request in a session
|
||||
pub fn update_latest_result(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
session: &mut Session,
|
||||
result_summary: &str,
|
||||
) -> Result<(), Error> {
|
||||
let requests_repo = SessionRequestsRepository::new(conn);
|
||||
if let Some(latest_request) = requests_repo.find_latest_by_session(session.id())
|
||||
.map_err(Error::Repository)?
|
||||
{
|
||||
requests_repo.update_result(latest_request.id, result_summary)
|
||||
.map_err(Error::Repository)?;
|
||||
|
||||
// Reload the session to get the updated request
|
||||
*session = self.load_session(conn, session.id())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fallback_session_name() {
|
||||
assert_eq!(
|
||||
StartupService::fallback_session_name("Hello world test"),
|
||||
"Hello world test"
|
||||
);
|
||||
|
||||
// Test with a long prompt that will be truncated after taking 5 words
|
||||
let result = StartupService::fallback_session_name("This is a very long prompt with many words that should be truncated");
|
||||
assert_eq!(result, "This is a very long"); // Only first 5 words, under 40 chars
|
||||
|
||||
// Test with 5 words that exceed 40 characters
|
||||
let result2 = StartupService::fallback_session_name("supercalifragilisticexpialidocious antidisestablishmentarianism pneumonoultramicroscopicsilicovolcanoconiosisword anotherverylongword");
|
||||
assert!(result2.len() <= 40);
|
||||
assert!(result2.ends_with("..."));
|
||||
|
||||
assert_eq!(
|
||||
StartupService::fallback_session_name(""),
|
||||
"New Session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_startup_config() {
|
||||
let config = StartupConfig { debug: false };
|
||||
assert!(!config.debug);
|
||||
|
||||
let service = StartupService::with_debug(true);
|
||||
assert!(service.config.debug);
|
||||
}
|
||||
}
|
||||
56
src/domain/tools/change/insert.rs
Normal file
56
src/domain/tools/change/insert.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
use super::Error;
|
||||
use crate::domain::tools::{Tool, ToolResult};
|
||||
use crate::utils::insert_content;
|
||||
use serde::Deserialize;
|
||||
use serde_yaml::Value as Yaml;
|
||||
|
||||
pub struct Insert;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InsertInput {
|
||||
full_path_to_file: String,
|
||||
target_line: usize,
|
||||
#[serde(default)]
|
||||
insert_content: String,
|
||||
}
|
||||
|
||||
impl Insert {
|
||||
fn parse_input(input: Yaml) -> Result<InsertInput, Error> {
|
||||
serde_yaml::from_value(input).map_err(|e| Error::InvalidYaml(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for Insert {
|
||||
fn name(&self) -> &'static str {
|
||||
"change_insert"
|
||||
}
|
||||
|
||||
fn work(&self, input: Yaml) -> ToolResult {
|
||||
let input_copy = input.clone();
|
||||
|
||||
match Self::parse_input(input) {
|
||||
Ok(parsed) => match insert_content(
|
||||
&parsed.full_path_to_file,
|
||||
parsed.target_line,
|
||||
&parsed.insert_content,
|
||||
) {
|
||||
Ok(()) => ToolResult::ok(self.name(), input_copy, Yaml::Null),
|
||||
Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()),
|
||||
},
|
||||
Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn desc(&self) -> &'static str {
|
||||
"Insert content at a specific line in an existing file"
|
||||
}
|
||||
|
||||
fn input_format(&self) -> &'static str {
|
||||
"
|
||||
input:
|
||||
full_path_to_file: string
|
||||
target_line: integer
|
||||
insert_content: string
|
||||
"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,37 @@
|
|||
pub mod change;
|
||||
mod find_files;
|
||||
mod read_file;
|
||||
mod find_objects;
|
||||
mod structure;
|
||||
mod insert;
|
||||
mod remove;
|
||||
mod replace;
|
||||
|
||||
pub use find_files::FindFiles;
|
||||
pub use find_objects::FindObjects;
|
||||
pub use read_file::ReadFile;
|
||||
pub use structure::Structure;
|
||||
pub use insert::Insert;
|
||||
pub use remove::Remove;
|
||||
pub use replace::Replace;
|
||||
|
||||
pub trait Tool {
|
||||
fn name(&self) -> &'static str;
|
||||
fn work(&self, input: &str) -> &'static str;
|
||||
fn desc(&self) -> &'static str;
|
||||
fn format(&self) -> &'static str;
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid yaml: {0}")]
|
||||
InvalidYaml(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ChangeOutput {
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl ChangeOutput {
|
||||
pub fn success() -> String {
|
||||
serde_yaml::to_string(&Self { ok: true, error: None }).unwrap_or_else(|_| "ok: true\n".to_string())
|
||||
}
|
||||
|
||||
pub fn failure(error: impl Into<String>) -> String {
|
||||
serde_yaml::to_string(&Self {
|
||||
ok: false,
|
||||
error: Some(error.into()),
|
||||
})
|
||||
.unwrap_or_else(|e| format!("ok: false\nerror: {}\n", e))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,53 @@
|
|||
use tools::Tool;
|
||||
use super::Error;
|
||||
use crate::domain::tools::{Tool, ToolResult};
|
||||
use crate::utils::remove_lines;
|
||||
use serde::Deserialize;
|
||||
use serde_yaml::Value as Yaml;
|
||||
|
||||
pub struct Insert;
|
||||
pub struct Remove;
|
||||
|
||||
impl Tool for Insert {
|
||||
#[derive(Deserialize)]
|
||||
struct RemoveInput {
|
||||
full_path_to_file: String,
|
||||
target_line: usize,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl Remove {
|
||||
fn parse_input(input: Yaml) -> Result<RemoveInput, Error> {
|
||||
serde_yaml::from_value(input).map_err(|e| Error::InvalidYaml(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for Remove {
|
||||
fn name(&self) -> &'static str {
|
||||
"change"
|
||||
"change_remove"
|
||||
}
|
||||
|
||||
fn work(&self, _input: &str) -> &'static str {
|
||||
""
|
||||
fn work(&self, input: Yaml) -> ToolResult {
|
||||
let input_copy = input.clone();
|
||||
|
||||
match Self::parse_input(input) {
|
||||
Ok(parsed) => {
|
||||
match remove_lines(&parsed.full_path_to_file, parsed.target_line, parsed.count) {
|
||||
Ok(()) => ToolResult::ok(self.name(), input_copy, Yaml::Null),
|
||||
Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()),
|
||||
}
|
||||
}
|
||||
Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn desc(&self) -> &'static str {
|
||||
"Apply an edit to an existing file"
|
||||
"Remove a specified number of lines from a file starting at a target line"
|
||||
}
|
||||
|
||||
fn format(&self) -> &'static str {
|
||||
fn input_format(&self) -> &'static str {
|
||||
"
|
||||
input:
|
||||
target_line: string # line in the file where to insert the change
|
||||
insert_content: string # content to insert
|
||||
output:
|
||||
ok: boolean
|
||||
error: string # optional
|
||||
full_path_to_file: string
|
||||
target_line: integer
|
||||
count: integer
|
||||
"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,61 @@
|
|||
use crate::domain::tools::Tool;
|
||||
use super::Error;
|
||||
use crate::domain::tools::{Tool, ToolResult};
|
||||
use crate::utils::replace_lines;
|
||||
use serde::Deserialize;
|
||||
use serde_yaml::Value as Yaml;
|
||||
|
||||
pub struct Insert;
|
||||
pub struct Replace;
|
||||
|
||||
impl Tool for Insert {
|
||||
#[derive(Deserialize)]
|
||||
struct ReplaceInput {
|
||||
full_path_to_file: String,
|
||||
start_line: usize,
|
||||
end_line: usize,
|
||||
#[serde(default)]
|
||||
content: String,
|
||||
}
|
||||
|
||||
impl Replace {
|
||||
fn parse_input(input: Yaml) -> Result<ReplaceInput, Error> {
|
||||
serde_yaml::from_value(input).map_err(|e| Error::InvalidYaml(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for Replace {
|
||||
fn name(&self) -> &'static str {
|
||||
"change_insert"
|
||||
"change_replace"
|
||||
}
|
||||
|
||||
fn work(&self, _input: &str) -> String {
|
||||
String::new()
|
||||
fn work(&self, input: Yaml) -> ToolResult {
|
||||
let input_copy = input.clone();
|
||||
|
||||
match Self::parse_input(input) {
|
||||
Ok(parsed) => {
|
||||
match replace_lines(
|
||||
&parsed.full_path_to_file,
|
||||
parsed.start_line,
|
||||
parsed.end_line,
|
||||
&parsed.content,
|
||||
) {
|
||||
Ok(()) => ToolResult::ok(self.name(), input_copy, Yaml::Null),
|
||||
Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()),
|
||||
}
|
||||
}
|
||||
Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn desc(&self) -> &'static str {
|
||||
"Apply an edit to an existing file"
|
||||
"Replace content between start_line and end_line (inclusive) with new content"
|
||||
}
|
||||
|
||||
fn format(&self) -> &'static str {
|
||||
fn input_format(&self) -> &'static str {
|
||||
"
|
||||
input:
|
||||
file_name: string # full path to the file
|
||||
target_line: string # line in the file where to insert the change
|
||||
insert_content: string # content to insert
|
||||
output:
|
||||
ok: boolean
|
||||
error: string # optional
|
||||
full_path_to_file: string
|
||||
start_line: integer
|
||||
end_line: integer
|
||||
content: string
|
||||
"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
src/domain/tools/find_files.rs
Normal file
31
src/domain/tools/find_files.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
use crate::domain::tools::{Tool, ToolResult};
|
||||
use serde_yaml::Value as Yaml;
|
||||
|
||||
pub struct FindFiles;
|
||||
|
||||
impl Tool for FindFiles {
|
||||
fn name(&self) -> &'static str {
|
||||
"find_files"
|
||||
}
|
||||
|
||||
fn work(&self, input: Yaml) -> ToolResult {
|
||||
ToolResult::ok(
|
||||
self.name(),
|
||||
input,
|
||||
Yaml::String("/Users/maksimtaisov/RustroverProjects/drastis/cats/cat.py".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn desc(&self) -> &'static str {
|
||||
"Find files under a root directory by substring match"
|
||||
}
|
||||
|
||||
fn input_format(&self) -> &'static str {
|
||||
"
|
||||
input:
|
||||
query: string # substring to match against path/filename
|
||||
root: string # optional, search root; default is project root
|
||||
max_results: integer # optional, default 20
|
||||
"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
use crate::domain::tools::{Tool, ToolResult};
|
||||
use serde_yaml::Value as Yaml;
|
||||
|
||||
pub struct Finish;
|
||||
|
||||
impl Tool for Finish {
|
||||
fn name(&self) -> &'static str {
|
||||
"finish"
|
||||
}
|
||||
|
||||
fn work(&self, input: Yaml) -> ToolResult {
|
||||
ToolResult::ok(
|
||||
self.name(),
|
||||
input,
|
||||
Yaml::String("The request is fulfilled".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn desc(&self) -> &'static str {
|
||||
"Finish working on user request, as everything asked for has been done."
|
||||
}
|
||||
|
||||
fn input_format(&self) -> &'static str {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +1,204 @@
|
|||
use crate::domain::tools::Tool;
|
||||
use crate::utils::{Lang, ParsedObject, UniversalParser};
|
||||
use crate::domain::tools::{Tool, ToolResult};
|
||||
use serde::Deserialize;
|
||||
use serde_yaml::Value as Yaml;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub struct FindObjects;
|
||||
pub struct ListObjects;
|
||||
|
||||
impl Tool for FindObjects {
|
||||
fn name(&self) -> &'static str {
|
||||
"find_objects"
|
||||
impl ListObjects {
|
||||
pub fn parse_file(file_path: &str) -> Result<(Lang, Vec<ParsedObject>), String> {
|
||||
let mut parser = UniversalParser::new();
|
||||
parser.parse_file(file_path)
|
||||
}
|
||||
|
||||
fn work(&self, _input: &str) -> &'static str {
|
||||
""
|
||||
fn parse_source(source: &str, lang: Lang) -> Result<Vec<ParsedObject>, String> {
|
||||
let mut parser = UniversalParser::new();
|
||||
parser.parse(source, lang)
|
||||
}
|
||||
|
||||
fn format_output(lang: Lang, objects: &[ParsedObject]) -> String {
|
||||
let mut output = String::new();
|
||||
output.push_str(&format!("language: {}\n", lang.name()));
|
||||
output.push_str("objects:\n");
|
||||
|
||||
let mut by_kind: BTreeMap<&str, Vec<&ParsedObject>> = BTreeMap::new();
|
||||
for obj in objects {
|
||||
by_kind.entry(obj.kind.name()).or_default().push(obj);
|
||||
}
|
||||
|
||||
for (kind, objs) in by_kind {
|
||||
output.push_str(&format!(" {}:\n", kind));
|
||||
for obj in objs {
|
||||
output.push_str(&format!(
|
||||
" - name: \"{}\"\n lines: {}-{}\n",
|
||||
obj.name, obj.line_start, obj.line_end
|
||||
));
|
||||
if let Some(ref vis) = obj.visibility {
|
||||
output.push_str(&format!(" visibility: {}\n", vis));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListObjectsInput {
|
||||
full_path_to_file: String,
|
||||
}
|
||||
|
||||
impl Tool for ListObjects {
|
||||
fn name(&self) -> &'static str {
|
||||
"list_objects"
|
||||
}
|
||||
|
||||
fn work(&self, input: Yaml) -> ToolResult {
|
||||
let input_copy = input.clone();
|
||||
|
||||
let parsed: ListObjectsInput = match serde_yaml::from_value(input) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return ToolResult::error(self.name(), input_copy, format!("invalid input: {}", e)),
|
||||
};
|
||||
|
||||
if parsed.full_path_to_file.is_empty() {
|
||||
return ToolResult::error(self.name(), input_copy, "full_path_to_file is required".to_string());
|
||||
}
|
||||
|
||||
match Self::parse_file(&parsed.full_path_to_file) {
|
||||
Ok((lang, objects)) => ToolResult::ok(self.name(), input_copy, Yaml::String(Self::format_output(lang, &objects))),
|
||||
Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn desc(&self) -> &'static str {
|
||||
"Lists objects by query (stub)"
|
||||
"Lists language-aware objects in a source file. Supports: Rust, Python, JavaScript, TypeScript, Go, Java, C, C++, Ruby, and more."
|
||||
}
|
||||
|
||||
fn format(&self) -> &'static str {
|
||||
fn input_format(&self) -> &'static str {
|
||||
"
|
||||
input:
|
||||
file_name: string # full path to the file
|
||||
query: string # what to search for
|
||||
output:
|
||||
results: array[string]
|
||||
full_path_to_file: string
|
||||
"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_rust_source() {
|
||||
let source = r#"
|
||||
pub struct MyStruct {
|
||||
field: i32,
|
||||
}
|
||||
|
||||
enum MyEnum {
|
||||
Variant1,
|
||||
Variant2,
|
||||
}
|
||||
|
||||
pub trait MyTrait {
|
||||
fn method(&self);
|
||||
}
|
||||
|
||||
impl MyTrait for MyStruct {
|
||||
fn method(&self) {}
|
||||
}
|
||||
|
||||
impl MyStruct {
|
||||
pub fn new() -> Self {
|
||||
Self { field: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
pub const MAX_SIZE: usize = 100;
|
||||
|
||||
fn private_function() {}
|
||||
|
||||
pub fn public_function(x: i32) -> i32 {
|
||||
x * 2
|
||||
}
|
||||
"#;
|
||||
let objects = ListObjects::parse_source(source, Lang::Rust).unwrap();
|
||||
|
||||
assert!(objects.iter().any(|o| o.name == "MyStruct"));
|
||||
assert!(objects.iter().any(|o| o.name == "MyEnum"));
|
||||
assert!(objects.iter().any(|o| o.name == "MyTrait"));
|
||||
assert!(objects.iter().any(|o| o.name == "MAX_SIZE"));
|
||||
assert!(objects.iter().any(|o| o.name == "private_function"));
|
||||
assert!(objects.iter().any(|o| o.name == "public_function"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_python_source() {
|
||||
let source = r#"
|
||||
def hello():
|
||||
pass
|
||||
|
||||
class MyClass:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def method(self):
|
||||
pass
|
||||
|
||||
async def async_func():
|
||||
pass
|
||||
"#;
|
||||
let objects = ListObjects::parse_source(source, Lang::Python).unwrap();
|
||||
|
||||
assert!(objects.iter().any(|o| o.name == "hello"));
|
||||
assert!(objects.iter().any(|o| o.name == "MyClass"));
|
||||
assert!(objects.iter().any(|o| o.name == "__init__"));
|
||||
assert!(objects.iter().any(|o| o.name == "method"));
|
||||
assert!(objects.iter().any(|o| o.name == "async_func"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_javascript_source() {
|
||||
let source = r#"
|
||||
function hello() {
|
||||
console.log("Hello");
|
||||
}
|
||||
|
||||
class MyClass {
|
||||
constructor() {}
|
||||
|
||||
method() {}
|
||||
}
|
||||
|
||||
const arrowFn = () => {};
|
||||
"#;
|
||||
let objects = ListObjects::parse_source(source, Lang::JavaScript).unwrap();
|
||||
|
||||
assert!(objects.iter().any(|o| o.name == "hello"));
|
||||
assert!(objects.iter().any(|o| o.name == "MyClass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_go_source() {
|
||||
let source = r#"
|
||||
package main
|
||||
|
||||
func hello() {
|
||||
fmt.Println("Hello")
|
||||
}
|
||||
|
||||
type Person struct {
|
||||
Name string
|
||||
Age int
|
||||
}
|
||||
|
||||
func (p *Person) Greet() string {
|
||||
return "Hello, " + p.Name
|
||||
}
|
||||
"#;
|
||||
let objects = ListObjects::parse_source(source, Lang::Go).unwrap();
|
||||
|
||||
assert!(objects.iter().any(|o| o.name == "hello"));
|
||||
assert!(objects.iter().any(|o| o.name == "Greet"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
87
src/domain/tools/mod.rs
Normal file
87
src/domain/tools/mod.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
pub mod change;
|
||||
mod find_files;
|
||||
mod list_objects;
|
||||
mod read_objects;
|
||||
mod structure;
|
||||
mod finish;
|
||||
|
||||
pub use find_files::FindFiles;
|
||||
pub use list_objects::ListObjects;
|
||||
pub use read_objects::ReadObjects;
|
||||
pub use structure::Structure;
|
||||
pub use finish::Finish;
|
||||
|
||||
use serde_yaml::Value as Yaml;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid yaml: {0}")]
|
||||
InvalidYaml(String),
|
||||
|
||||
#[error("parse error: {0}")]
|
||||
Parse(String),
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
pub struct ToolResult {
|
||||
tool_name: String,
|
||||
input: Yaml,
|
||||
is_successful: bool,
|
||||
success_output: Yaml,
|
||||
error_message: String,
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
pub fn ok(tool_name: impl Into<String>, input: Yaml, output: Yaml) -> Self {
|
||||
Self {
|
||||
tool_name: tool_name.into(),
|
||||
input,
|
||||
is_successful: true,
|
||||
success_output: output,
|
||||
error_message: "".to_string(),
|
||||
}
|
||||
}
|
||||
pub fn error(tool_name: impl Into<String>, input: Yaml, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_name: tool_name.into(),
|
||||
input,
|
||||
is_successful: false,
|
||||
success_output: Yaml::Null,
|
||||
error_message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn output_string(&self) -> String {
|
||||
if self.is_successful {
|
||||
serde_yaml::to_string(&self.success_output)
|
||||
.unwrap_or_else(|_| format!("{:?}", self.success_output))
|
||||
} else {
|
||||
format!("Error: {}", self.error_message)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_string(&self) -> String {
|
||||
serde_yaml::to_string(&self.input)
|
||||
.unwrap_or_else(|_| format!("{:?}", self.input))
|
||||
}
|
||||
|
||||
/// Generate a summary string for this tool result
|
||||
/// Format: "Tool `tool_name` was executed successfully" or "Tool `tool_name` failed: error_message"
|
||||
pub fn summary(&self) -> String {
|
||||
if self.is_successful {
|
||||
format!("Tool `{}` was executed successfully", self.tool_name)
|
||||
} else {
|
||||
format!("Tool `{}` failed: {}", self.tool_name, self.error_message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Tool {
|
||||
fn name(&self) -> &'static str;
|
||||
fn work(&self, input: Yaml) -> ToolResult;
|
||||
fn desc(&self) -> &'static str;
|
||||
fn input_format(&self) -> &'static str;
|
||||
}
|
||||
|
|
@ -1,29 +1,301 @@
|
|||
use crate::domain::tools::Tool;
|
||||
use super::{Error, Tool, ToolResult};
|
||||
use crate::utils::{Lang, ObjectKind, ParsedObject, UniversalParser};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml::Value as Yaml;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
pub struct ListObjects;
|
||||
pub struct ReadObjects;
|
||||
|
||||
impl Tool for ListObjects {
|
||||
fn name(&self) -> &'static str {
|
||||
"list_objects"
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ObjectContent {
|
||||
pub kind: String,
|
||||
pub line_start: usize,
|
||||
pub line_end: usize,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ObjectQuery {
|
||||
pub kind: Option<ObjectKind>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl ObjectQuery {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
kind: None,
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn work(&self, _input: &str) -> &'static str {
|
||||
""
|
||||
pub fn with_kind(kind: ObjectKind, name: &str) -> Self {
|
||||
Self {
|
||||
kind: Some(kind),
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(&self, obj: &ParsedObject) -> bool {
|
||||
let name_matches = obj.name == self.name || obj.name.contains(&self.name);
|
||||
|
||||
match self.kind {
|
||||
Some(kind) => kind == obj.kind && name_matches,
|
||||
None => name_matches,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReadObjectsInput {
|
||||
full_path_to_file: String,
|
||||
queries: Vec<QueryInput>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueryInput {
|
||||
#[serde(default)]
|
||||
kind: Option<String>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ReadObjectsOutput {
|
||||
language: String,
|
||||
results: HashMap<String, ObjectContent>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorOutput {
|
||||
error: String,
|
||||
}
|
||||
|
||||
impl ReadObjects {
|
||||
pub fn read_objects(
|
||||
file_path: &str,
|
||||
queries: &[ObjectQuery],
|
||||
) -> Result<(Lang, HashMap<String, ObjectContent>), Error> {
|
||||
let source_code =
|
||||
fs::read_to_string(file_path).map_err(|e| Error::Io(format!("failed to read file: {}", e)))?;
|
||||
|
||||
let mut parser = UniversalParser::new();
|
||||
let (lang, objects) = parser.parse_file(file_path)
|
||||
.map_err(Error::Parse)?;
|
||||
|
||||
let lines: Vec<&str> = source_code.lines().collect();
|
||||
let mut results = HashMap::new();
|
||||
|
||||
for query in queries {
|
||||
for obj in &objects {
|
||||
if query.matches(obj) {
|
||||
let content = Self::extract_content(obj, &lines);
|
||||
results.insert(obj.name.clone(), content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((lang, results))
|
||||
}
|
||||
|
||||
fn extract_content(obj: &ParsedObject, lines: &[&str]) -> ObjectContent {
|
||||
let start_idx = obj.line_start.saturating_sub(1);
|
||||
let end_idx = obj.line_end.min(lines.len());
|
||||
|
||||
let content = lines[start_idx..end_idx].join("\n");
|
||||
|
||||
ObjectContent {
|
||||
kind: obj.kind.name().to_string(),
|
||||
line_start: obj.line_start,
|
||||
line_end: obj.line_end,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_input(input: Yaml) -> Result<ReadObjectsInput, Error> {
|
||||
let parsed: ReadObjectsInput =
|
||||
serde_yaml::from_value(input).map_err(|e| Error::InvalidYaml(e.to_string()))?;
|
||||
|
||||
if parsed.queries.is_empty() {
|
||||
return Err(Error::Parse("at least one query is required".into()));
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn format_output(lang: Lang, results: HashMap<String, ObjectContent>) -> String {
|
||||
let output = ReadObjectsOutput {
|
||||
language: lang.name().to_string(),
|
||||
results,
|
||||
};
|
||||
serde_yaml::to_string(&output).unwrap_or_else(|e| format!("error: {}", e))
|
||||
}
|
||||
|
||||
fn format_error(error: impl Into<String>) -> String {
|
||||
serde_yaml::to_string(&ErrorOutput {
|
||||
error: error.into(),
|
||||
})
|
||||
.unwrap_or_else(|e| format!("error: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for ReadObjects {
|
||||
fn name(&self) -> &'static str {
|
||||
"read_objects"
|
||||
}
|
||||
|
||||
fn work(&self, input: Yaml) -> ToolResult {
|
||||
let input_copy = input.clone();
|
||||
|
||||
match Self::parse_input(input) {
|
||||
Ok(parsed) => {
|
||||
let queries: Vec<ObjectQuery> = parsed
|
||||
.queries
|
||||
.into_iter()
|
||||
.map(|q| match q.kind {
|
||||
Some(kind_str) => ObjectQuery {
|
||||
kind: ObjectKind::from_str(&kind_str),
|
||||
name: q.name,
|
||||
},
|
||||
None => ObjectQuery::new(&q.name),
|
||||
})
|
||||
.collect();
|
||||
|
||||
match Self::read_objects(&parsed.full_path_to_file, &queries) {
|
||||
Ok((lang, results)) => ToolResult::ok(self.name(), input_copy, Yaml::String(Self::format_output(lang, results))),
|
||||
Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()),
|
||||
}
|
||||
}
|
||||
Err(e) => ToolResult::error(self.name(), input_copy, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn desc(&self) -> &'static str {
|
||||
"Lists language-aware-objects in a file (like functions, classes, etc.)"
|
||||
"Shows code for requested objects in a source file. Supports: Rust, Python, JavaScript, TypeScript, Go, Java, C, C++, Ruby, and more."
|
||||
}
|
||||
|
||||
fn format(&self) -> &'static str {
|
||||
fn input_format(&self) -> &'static str {
|
||||
"
|
||||
input:
|
||||
file_name: string # full path to the file
|
||||
output:
|
||||
results:
|
||||
classes: [string]
|
||||
functions: [string]
|
||||
constants: [string]
|
||||
full_path_to_file: string
|
||||
queries:
|
||||
- name: string
|
||||
kind: string
|
||||
"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_read_rust_objects() {
|
||||
let source = r#"
|
||||
pub struct MyStruct {
|
||||
field: i32,
|
||||
}
|
||||
|
||||
impl MyStruct {
|
||||
pub fn new() -> Self {
|
||||
Self { field: 0 }
|
||||
}
|
||||
|
||||
pub fn get_field(&self) -> i32 {
|
||||
self.field
|
||||
}
|
||||
}
|
||||
|
||||
pub fn standalone_fn() -> i32 {
|
||||
42
|
||||
}
|
||||
"#;
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("test.rs");
|
||||
std::fs::write(&file_path, source).unwrap();
|
||||
|
||||
let queries = vec![
|
||||
ObjectQuery::with_kind(ObjectKind::Struct, "MyStruct"),
|
||||
ObjectQuery::with_kind(ObjectKind::Function, "new"),
|
||||
ObjectQuery::with_kind(ObjectKind::Function, "standalone_fn"),
|
||||
];
|
||||
|
||||
let (lang, results) =
|
||||
ReadObjects::read_objects(file_path.to_str().unwrap(), &queries).unwrap();
|
||||
|
||||
assert_eq!(lang, Lang::Rust);
|
||||
assert_eq!(results.len(), 3);
|
||||
|
||||
let struct_result = results.get("MyStruct").unwrap();
|
||||
assert_eq!(struct_result.kind, "struct");
|
||||
assert!(struct_result.content.contains("pub struct MyStruct"));
|
||||
|
||||
let new_result = results.get("new").unwrap();
|
||||
assert_eq!(new_result.kind, "function");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_python_objects() {
|
||||
let source = r#"
|
||||
def hello():
|
||||
print("Hello")
|
||||
|
||||
class MyClass:
|
||||
def method(self):
|
||||
pass
|
||||
"#;
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("test.py");
|
||||
std::fs::write(&file_path, source).unwrap();
|
||||
|
||||
let queries = vec![
|
||||
ObjectQuery::new("hello"),
|
||||
ObjectQuery::with_kind(ObjectKind::Class, "MyClass"),
|
||||
];
|
||||
|
||||
let (lang, results) =
|
||||
ReadObjects::read_objects(file_path.to_str().unwrap(), &queries).unwrap();
|
||||
|
||||
assert_eq!(lang, Lang::Python);
|
||||
assert!(results.contains_key("hello"));
|
||||
assert!(results.contains_key("MyClass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_input() {
|
||||
let input = r#"
|
||||
full_path_to_file: /path/to/file.rs
|
||||
queries:
|
||||
- name: main
|
||||
kind: function
|
||||
- name: Config
|
||||
kind: struct
|
||||
- name: Parser
|
||||
"#;
|
||||
let yaml: Yaml = serde_yaml::from_str(input).unwrap();
|
||||
let parsed = ReadObjects::parse_input(yaml).unwrap();
|
||||
|
||||
assert_eq!(parsed.full_path_to_file, "/path/to/file.rs");
|
||||
assert_eq!(parsed.queries.len(), 3);
|
||||
assert_eq!(parsed.queries[0].kind, Some("function".to_string()));
|
||||
assert_eq!(parsed.queries[1].kind, Some("struct".to_string()));
|
||||
assert_eq!(parsed.queries[2].kind, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_matching() {
|
||||
let obj = ParsedObject {
|
||||
name: "my_function".to_string(),
|
||||
kind: ObjectKind::Function,
|
||||
line_start: 1,
|
||||
line_end: 5,
|
||||
byte_start: 0,
|
||||
byte_end: 100,
|
||||
visibility: None,
|
||||
};
|
||||
|
||||
assert!(ObjectQuery::new("my_function").matches(&obj));
|
||||
assert!(ObjectQuery::with_kind(ObjectKind::Function, "my_function").matches(&obj));
|
||||
assert!(!ObjectQuery::with_kind(ObjectKind::Class, "my_function").matches(&obj));
|
||||
assert!(ObjectQuery::new("function").matches(&obj));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
src/domain/tools/structure.rs
Normal file
26
src/domain/tools/structure.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use crate::domain::tools::{Tool, ToolResult};
|
||||
use serde_yaml::Value as Yaml;
|
||||
|
||||
pub struct Structure;
|
||||
|
||||
impl Tool for Structure {
|
||||
fn name(&self) -> &'static str {
|
||||
"structure"
|
||||
}
|
||||
|
||||
fn work(&self, input: Yaml) -> ToolResult {
|
||||
ToolResult::ok(self.name(), input, Yaml::String(String::new()))
|
||||
}
|
||||
|
||||
fn desc(&self) -> &'static str {
|
||||
"Return the directory structure up to a given depth"
|
||||
}
|
||||
|
||||
fn input_format(&self) -> &'static str {
|
||||
"
|
||||
input:
|
||||
path: string
|
||||
max_depth: integer
|
||||
"
|
||||
}
|
||||
}
|
||||
243
src/domain/workflow/chain.rs
Normal file
243
src/domain/workflow/chain.rs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
use super::{step::ChainStep, Error};
|
||||
use crate::domain::tools::ToolResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml::Value as Yaml;
|
||||
|
||||
use super::step::StepType;
|
||||
|
||||
/// Represents an execution chain containing multiple steps
|
||||
/// The chain is built incrementally as tools are executed
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Chain {
|
||||
pub steps: Vec<ChainStep>,
|
||||
pub is_failed: bool,
|
||||
pub fail_reason: String,
|
||||
}
|
||||
|
||||
impl Chain {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
steps: Vec::new(),
|
||||
is_failed: false,
|
||||
fail_reason: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a step to the chain after executing a tool
|
||||
pub fn add_step(&mut self, result: ToolResult) {
|
||||
self.steps.push(ChainStep::new(
|
||||
StepType::ToolCall,
|
||||
Some(result),
|
||||
));
|
||||
}
|
||||
|
||||
/// Mark the chain as failed with a reason
|
||||
pub fn mark_failed(&mut self, reason: String) {
|
||||
self.is_failed = true;
|
||||
self.fail_reason = reason;
|
||||
}
|
||||
|
||||
/// Add a user interruption step
|
||||
pub fn add_interruption(&mut self) {
|
||||
self.steps.push(ChainStep::new(
|
||||
StepType::UserInterruption,
|
||||
None,
|
||||
));
|
||||
self.mark_failed("User interrupted".to_string());
|
||||
}
|
||||
|
||||
pub fn steps(&self) -> &[ChainStep] {
|
||||
&self.steps
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.steps.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.steps.is_empty()
|
||||
}
|
||||
|
||||
/// Get the finish message from the finish tool output
|
||||
/// Returns the output of the finish tool if found, otherwise returns a default message
|
||||
pub fn get_finish_message(&self) -> String {
|
||||
// Look for the finish tool step by checking if summary contains "finish"
|
||||
if let Some(finish_step) = self.steps.iter().find(|step|
|
||||
step.step_type == StepType::ToolCall.as_str() &&
|
||||
step.summary.contains("finish")
|
||||
) {
|
||||
// Extract message from summary like "Tool `finish` was executed successfully"
|
||||
// For finish tool, we want to return a success message
|
||||
if finish_step.summary.contains("successfully") {
|
||||
return "The request is fulfilled".to_string();
|
||||
}
|
||||
finish_step.summary.clone()
|
||||
} else {
|
||||
// No finish tool found, return a default message
|
||||
if self.is_empty() {
|
||||
"No steps executed.".to_string()
|
||||
} else {
|
||||
format!("Workflow completed. Executed {} steps.", self.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a summary of the chain execution
|
||||
/// Returns a string describing how many tool calls were executed and whether it was successful
|
||||
/// This should be saved to session_request.result_summary
|
||||
pub fn get_summary(&self) -> String {
|
||||
let tool_call_count = self.steps.iter()
|
||||
.filter(|s| s.step_type == StepType::ToolCall.as_str())
|
||||
.count();
|
||||
|
||||
if self.is_failed {
|
||||
format!("Executed {} tool calls. Failed: {}", tool_call_count, self.fail_reason)
|
||||
} else {
|
||||
format!("Executed {} tool calls. Success.", tool_call_count)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the log of all steps
|
||||
/// Returns text consisting of chain_step.summary for each step
|
||||
/// This should be saved to session_request.steps_log
|
||||
pub fn get_log(&self) -> String {
|
||||
self.steps.iter()
|
||||
.map(|step| step.summary.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single tool choice from LLM output
|
||||
/// Expected format (possibly wrapped in ```yaml ... ```):
|
||||
/// ```yaml
|
||||
/// tool_name: read_objects
|
||||
/// input:
|
||||
/// full_path_to_file: cat.py
|
||||
/// queries:
|
||||
/// - name: foo
|
||||
/// kind: method
|
||||
/// ```
|
||||
pub fn parse_tool_choice(llm_output: &str) -> Result<(String, Yaml), Error> {
|
||||
// Strip markdown code block markers if present
|
||||
let content = llm_output
|
||||
.trim()
|
||||
.strip_prefix("```yaml")
|
||||
.or_else(|| llm_output.trim().strip_prefix("```"))
|
||||
.unwrap_or(llm_output.trim());
|
||||
|
||||
let content = content
|
||||
.trim()
|
||||
.strip_suffix("```")
|
||||
.unwrap_or(content)
|
||||
.trim();
|
||||
|
||||
// Parse the YAML
|
||||
let parsed: Yaml = serde_yaml::from_str(content)
|
||||
.map_err(|e| Error::Parse(format!("invalid yaml: {}", e)))?;
|
||||
|
||||
// Extract tool_name
|
||||
let tool_name = parsed
|
||||
.get("tool_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::Parse("no tool_name found in LLM output".into()))?;
|
||||
|
||||
if tool_name.is_empty() {
|
||||
return Err(Error::Parse("empty tool_name".into()));
|
||||
}
|
||||
|
||||
// Extract input field (default to empty mapping if not present)
|
||||
let input = parsed
|
||||
.get("input")
|
||||
.cloned()
|
||||
.unwrap_or(Yaml::Mapping(serde_yaml::Mapping::new()));
|
||||
|
||||
Ok((tool_name.to_string(), input))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_tool_choice() {
|
||||
let output = r#"```yaml
|
||||
tool_name: read_objects
|
||||
input:
|
||||
full_path_to_file: cat.py
|
||||
queries:
|
||||
- name: foo
|
||||
kind: method
|
||||
```"#;
|
||||
let (tool_name, input) = parse_tool_choice(output).unwrap();
|
||||
assert_eq!(tool_name, "read_objects");
|
||||
assert_eq!(input["full_path_to_file"].as_str(), Some("cat.py"));
|
||||
assert!(input["queries"].is_sequence());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tool_choice_no_markers() {
|
||||
let output = r#"tool_name: change_replace
|
||||
input:
|
||||
full_path_to_file: main.rs
|
||||
start_line: 10
|
||||
end_line: 15
|
||||
content: |
|
||||
fn new_code() {
|
||||
println!("hello");
|
||||
}"#;
|
||||
let (tool_name, input) = parse_tool_choice(output).unwrap();
|
||||
assert_eq!(tool_name, "change_replace");
|
||||
assert_eq!(input["start_line"].as_u64(), Some(10));
|
||||
assert_eq!(input["end_line"].as_u64(), Some(15));
|
||||
assert!(input["content"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chain_add_step() {
|
||||
use crate::domain::tools::ToolResult;
|
||||
use crate::domain::workflow::step::StepType;
|
||||
use serde_yaml::Value as Yaml;
|
||||
|
||||
let mut chain = Chain::new();
|
||||
let result = ToolResult::ok("read_file", Yaml::String("file: test.rs".to_string()), Yaml::String("content here".to_string()));
|
||||
chain.add_step(result);
|
||||
|
||||
assert_eq!(chain.len(), 1);
|
||||
assert_eq!(chain.steps[0].step_type, StepType::ToolCall.as_str());
|
||||
assert!(chain.steps[0].summary.contains("read_file"));
|
||||
assert!(chain.steps[0].summary.contains("successfully"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chain_get_summary() {
|
||||
use crate::domain::tools::ToolResult;
|
||||
use serde_yaml::Value as Yaml;
|
||||
|
||||
let mut chain = Chain::new();
|
||||
let result = ToolResult::ok("read_file", Yaml::Null, Yaml::Null);
|
||||
chain.add_step(result);
|
||||
|
||||
let summary = chain.get_summary();
|
||||
assert!(summary.contains("Executed 1 tool calls"));
|
||||
assert!(summary.contains("Success"));
|
||||
assert!(!chain.is_failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chain_get_log() {
|
||||
use crate::domain::tools::ToolResult;
|
||||
use serde_yaml::Value as Yaml;
|
||||
|
||||
let mut chain = Chain::new();
|
||||
let result1 = ToolResult::ok("read_file", Yaml::Null, Yaml::Null);
|
||||
chain.add_step(result1);
|
||||
let result2 = ToolResult::ok("find_files", Yaml::Null, Yaml::Null);
|
||||
chain.add_step(result2);
|
||||
|
||||
let log = chain.get_log();
|
||||
assert!(log.contains("read_file"));
|
||||
assert!(log.contains("find_files"));
|
||||
assert_eq!(log.lines().count(), 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
pub mod workflow;
|
||||
pub mod chain;
|
||||
pub mod step;
|
||||
pub mod toolset;
|
||||
|
||||
pub use chain::Chain;
|
||||
pub use step::{ChainStep, StepType};
|
||||
pub use toolset::Toolset;
|
||||
pub use workflow::Workflow;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
|
||||
/// A token that can be used to signal cancellation to running operations.
|
||||
/// Clone is cheap - it just clones the Arc.
|
||||
#[derive(Clone)]
|
||||
pub struct CancellationToken {
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl CancellationToken {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if cancellation has been requested
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Request cancellation
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Reset the token for reuse
|
||||
pub fn reset(&self) {
|
||||
self.cancelled.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CancellationToken {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("no prompt set in session")]
|
||||
NoPromptSet,
|
||||
|
||||
#[error("inference failed: {0}")]
|
||||
Inference(String),
|
||||
|
||||
#[error("parse error: {0}")]
|
||||
Parse(String),
|
||||
|
||||
#[error("tool not found: {0}")]
|
||||
ToolNotFound(String),
|
||||
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl From<Error> for String {
|
||||
fn from(err: Error) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::tools::ToolResult;
|
||||
|
||||
/// Types of steps that can occur in an execution chain
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum StepType {
|
||||
/// A tool was called and executed
|
||||
ToolCall,
|
||||
/// User interrupted the workflow (Ctrl+C)
|
||||
UserInterruption,
|
||||
/// A todo item was created
|
||||
TodoCreation,
|
||||
/// A todo item was updated
|
||||
TodoUpdate,
|
||||
}
|
||||
|
||||
impl StepType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
StepType::ToolCall => "tool_call",
|
||||
StepType::UserInterruption => "user_interruption",
|
||||
StepType::TodoCreation => "todo_creation",
|
||||
StepType::TodoUpdate => "todo_update",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a single step in an execution chain
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChainStep {
|
||||
pub step_type: String,
|
||||
pub summary: String,
|
||||
pub context_payload: String,
|
||||
pub input_payload: String,
|
||||
}
|
||||
|
||||
impl ChainStep {
|
||||
pub fn new(step_type: StepType, tool_result: Option<ToolResult>) -> Self {
|
||||
let mut summary = String::new();
|
||||
if step_type == StepType::UserInterruption {
|
||||
summary = "User interrupted".to_string();
|
||||
}
|
||||
let mut context_payload = String::new();
|
||||
let mut input_payload = String::new();
|
||||
if let Some(tr) = tool_result {
|
||||
summary = tr.summary();
|
||||
context_payload = tr.output_string();
|
||||
input_payload = tr.input_string();
|
||||
}
|
||||
|
||||
Self {
|
||||
step_type: step_type.as_str().to_string(),
|
||||
summary,
|
||||
context_payload,
|
||||
input_payload,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
use super::Error;
|
||||
use crate::domain::prompting::format_tools_description;
|
||||
use crate::domain::tools::{Tool, ToolResult, FindFiles, ListObjects, ReadObjects, Structure};
|
||||
use serde_yaml::Value as Yaml;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Manages the available tools and provides descriptions for LLM planning
|
||||
pub struct Toolset {
|
||||
tools: HashMap<String, Box<dyn Tool>>,
|
||||
}
|
||||
|
||||
impl Toolset {
|
||||
pub fn new() -> Self {
|
||||
let mut tools: HashMap<String, Box<dyn Tool>> = HashMap::new();
|
||||
|
||||
// Register all available tools
|
||||
let list_objects = Box::new(ListObjects);
|
||||
tools.insert(list_objects.name().to_string(), list_objects);
|
||||
|
||||
let read_objects = Box::new(ReadObjects);
|
||||
tools.insert(read_objects.name().to_string(), read_objects);
|
||||
|
||||
let find_files = Box::new(FindFiles);
|
||||
tools.insert(find_files.name().to_string(), find_files);
|
||||
|
||||
let structure = Box::new(Structure);
|
||||
tools.insert(structure.name().to_string(), structure);
|
||||
|
||||
let finish = Box::new(crate::domain::tools::Finish);
|
||||
tools.insert(finish.name().to_string(), finish);
|
||||
|
||||
let insert = Box::new(crate::domain::tools::change::Insert);
|
||||
tools.insert(insert.name().to_string(), insert);
|
||||
|
||||
let remove = Box::new(crate::domain::tools::change::Remove);
|
||||
tools.insert(remove.name().to_string(), remove);
|
||||
|
||||
let replace = Box::new(crate::domain::tools::change::Replace);
|
||||
tools.insert(replace.name().to_string(), replace);
|
||||
|
||||
Self { tools }
|
||||
}
|
||||
|
||||
/// Get a tool by name
|
||||
pub fn get_tool(&self, name: &str) -> Option<&dyn Tool> {
|
||||
self.tools.get(name).map(|t| t.as_ref())
|
||||
}
|
||||
|
||||
/// Generate a formatted description of all available tools for LLM planning
|
||||
pub fn get_tools_description(&self) -> String {
|
||||
format_tools_description(self.tools.iter().map(|(name, tool)| (name.as_str(), tool.as_ref())))
|
||||
}
|
||||
|
||||
/// Execute a tool with the given input
|
||||
pub fn execute_tool(&self, tool_name: &str, input: Yaml) -> Result<ToolResult, Error> {
|
||||
match self.get_tool(tool_name) {
|
||||
Some(tool) => {
|
||||
let result = tool.work(input);
|
||||
Ok(result)
|
||||
}
|
||||
None => Err(Error::ToolNotFound(tool_name.into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Toolset {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_tool() {
|
||||
let toolset = Toolset::new();
|
||||
assert!(toolset.get_tool("read_objects").is_some());
|
||||
assert!(toolset.get_tool("find_files").is_some());
|
||||
assert!(toolset.get_tool("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tools_description() {
|
||||
let toolset = Toolset::new();
|
||||
let description = toolset.get_tools_description();
|
||||
assert!(description.contains("Available Tools:"));
|
||||
assert!(description.contains("read_objects"));
|
||||
assert!(description.contains("finish"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
use super::{chain::Chain, toolset::Toolset, CancellationToken, Error};
|
||||
use crate::domain::prompting;
|
||||
use crate::domain::session::Session;
|
||||
use crate::domain::tools::ToolResult;
|
||||
use crate::infrastructure::inference::InferenceEngine;
|
||||
|
||||
/// Main workflow orchestrator that runs LLM-driven coding tasks
|
||||
/// Implements an eternal agent loop that:
|
||||
/// 1. Asks LLM to choose the next tool
|
||||
/// 2. Executes that tool
|
||||
/// 3. Stores the result in the chain
|
||||
/// 4. Repeats until "finish" tool is chosen or 128 iterations reached
|
||||
pub struct Workflow {
|
||||
toolset: Toolset,
|
||||
}
|
||||
|
||||
impl Workflow {
|
||||
/// Create a new workflow with default toolset
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
toolset: Toolset::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a workflow with a custom toolset
|
||||
pub fn with_toolset(toolset: Toolset) -> Self {
|
||||
Self { toolset }
|
||||
}
|
||||
|
||||
/// Run the workflow for a given session
|
||||
/// Implements the eternal agent loop:
|
||||
/// - Asks LLM to choose next tool
|
||||
/// - Executes the tool
|
||||
/// - Stores result in chain
|
||||
/// - Repeats until "finish" or 128 iterations
|
||||
///
|
||||
/// The workflow checks the cancellation token before each iteration
|
||||
/// and returns Error::Cancelled if cancellation was requested.
|
||||
pub fn run(
|
||||
&self,
|
||||
session: &Session,
|
||||
engine: &InferenceEngine,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Chain, Error> {
|
||||
const MAX_ITERATIONS: usize = 128;
|
||||
|
||||
// Get the user prompt from the session
|
||||
let user_prompt = prompting::format_session_prompt(&session);
|
||||
|
||||
// Initialize the chain to track executed steps
|
||||
let mut chain = Chain::new();
|
||||
|
||||
// Eternal agent loop
|
||||
for iteration in 1..=MAX_ITERATIONS {
|
||||
// Check for cancellation at the start of each iteration
|
||||
if cancel.is_cancelled() {
|
||||
log::info!("Workflow cancelled by user");
|
||||
chain.add_interruption();
|
||||
return Ok(chain);
|
||||
}
|
||||
|
||||
// Create prompt with chain context
|
||||
let prompt = prompting::tool_selection_prompt(&user_prompt, &self.toolset, &chain);
|
||||
|
||||
// Ask LLM to choose next tool
|
||||
let llm_output = engine.generate_silent(&prompt, 1024)
|
||||
.map_err(Error::Inference)?;
|
||||
|
||||
// Check for cancellation after inference
|
||||
if cancel.is_cancelled() {
|
||||
log::info!("Workflow cancelled by user");
|
||||
chain.add_interruption();
|
||||
return Ok(chain);
|
||||
}
|
||||
|
||||
// Parse tool choice from LLM output
|
||||
let (tool_name, input_yaml) = super::chain::parse_tool_choice(&llm_output)?;
|
||||
|
||||
// Check if we should finish
|
||||
if tool_name == "finish" {
|
||||
log::info!("Finish tool chosen, ending workflow");
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
let tool_result = match self.toolset.execute_tool(&tool_name, input_yaml.clone()) {
|
||||
Ok(result) => {
|
||||
log::info!("Tool {} executed successfully", tool_name);
|
||||
result
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("Tool {} failed: {}", tool_name, error);
|
||||
let error_msg = format!("Tool execution failed: {}", error);
|
||||
// Create an error ToolResult for consistency
|
||||
let result = ToolResult::error(
|
||||
tool_name.clone(),
|
||||
input_yaml.clone(),
|
||||
error_msg.clone(),
|
||||
);
|
||||
chain.add_step(result);
|
||||
chain.mark_failed(error_msg);
|
||||
return Ok(chain);
|
||||
}
|
||||
};
|
||||
|
||||
// Add step to chain
|
||||
chain.add_step(tool_result);
|
||||
}
|
||||
|
||||
if chain.len() >= MAX_ITERATIONS {
|
||||
log::warn!("Workflow reached maximum iterations ({})", MAX_ITERATIONS);
|
||||
chain.mark_failed("Maximum iterations reached".to_string());
|
||||
}
|
||||
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// Get the toolset (for testing or inspection)
|
||||
pub fn toolset(&self) -> &Toolset {
|
||||
&self.toolset
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Workflow {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
128
src/infrastructure/db.rs
Normal file
128
src/infrastructure/db.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
use directories::ProjectDirs;
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Resolves the OS-specific data directory for the application.
|
||||
fn get_data_dir(app_name: &str) -> Result<PathBuf, String> {
|
||||
ProjectDirs::from("", "", app_name)
|
||||
.map(|dirs| dirs.data_dir().to_path_buf())
|
||||
.ok_or_else(|| "Failed to resolve application data directory".to_string())
|
||||
}
|
||||
|
||||
/// Initializes the SQLite database, creating it if necessary.
|
||||
/// Returns an open Connection or exits with an error.
|
||||
pub fn init_db(app_name: &str, _debug: bool) -> Result<Connection, String> {
|
||||
let data_dir = get_data_dir(app_name)?;
|
||||
|
||||
// Ensure parent directories exist
|
||||
fs::create_dir_all(&data_dir).map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = data_dir.join("agent.db");
|
||||
let db_exists = db_path.exists();
|
||||
|
||||
// Open or create database
|
||||
let conn =
|
||||
Connection::open(&db_path).map_err(|e| format!("Failed to open database: {}", e))?;
|
||||
|
||||
if db_exists {
|
||||
log::info!("Database opened: {}", db_path.display());
|
||||
} else {
|
||||
log::info!("Database created: {}", db_path.display());
|
||||
}
|
||||
|
||||
// Set pragmas
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA busy_timeout = 5000;",
|
||||
)
|
||||
.map_err(|e| format!("Failed to set pragmas: {}", e))?;
|
||||
|
||||
// Create tables
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
session_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
result_summary TEXT,
|
||||
steps_log TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create tables: {}", e))?;
|
||||
|
||||
// Get current schema version
|
||||
let current_version: i32 = conn
|
||||
.query_row(
|
||||
"SELECT value FROM meta WHERE key = 'schema_version'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1); // Default to version 1 if not found
|
||||
|
||||
// Run migrations if needed
|
||||
if current_version < 2 {
|
||||
log::info!("Migrating database from version {} to 2", current_version);
|
||||
|
||||
// Migration to version 2: add steps_log column
|
||||
// Check if column already exists by querying table info
|
||||
let column_exists = conn
|
||||
.prepare("SELECT name FROM pragma_table_info('session_requests') WHERE name = 'steps_log'")
|
||||
.and_then(|mut stmt| {
|
||||
stmt.query_map([], |row| row.get::<_, String>(0))
|
||||
.map(|mut rows| rows.next().is_some())
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if !column_exists {
|
||||
log::info!("Adding steps_log column to session_requests table");
|
||||
conn.execute(
|
||||
"ALTER TABLE session_requests ADD COLUMN steps_log TEXT",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add steps_log column: {}", e))?;
|
||||
}
|
||||
|
||||
// Update schema version
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', '2')",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update schema_version: {}", e))?;
|
||||
|
||||
log::info!("Database migration completed");
|
||||
} else {
|
||||
// Insert schema_version if missing (for new databases)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '2')",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert schema_version: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
219
src/infrastructure/inference.rs
Normal file
219
src/infrastructure/inference.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
use llama_cpp_2::context::params::LlamaContextParams;
|
||||
use llama_cpp_2::llama_backend::LlamaBackend;
|
||||
use llama_cpp_2::llama_batch::LlamaBatch;
|
||||
use llama_cpp_2::model::params::LlamaModelParams;
|
||||
use llama_cpp_2::model::{AddBos, LlamaModel, Special};
|
||||
use llama_cpp_2::sampling::LlamaSampler;
|
||||
use llama_cpp_2::{send_logs_to_tracing, LogOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::Path;
|
||||
|
||||
pub struct InferenceParams {
|
||||
pub ctx_size: u32,
|
||||
pub temperature: f32,
|
||||
pub top_p: f32,
|
||||
pub max_tokens: u32,
|
||||
pub threads: i32,
|
||||
}
|
||||
|
||||
impl Default for InferenceParams {
|
||||
fn default() -> Self {
|
||||
let cpu_count = num_cpus::get() as i32;
|
||||
Self {
|
||||
ctx_size: 4096,
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
max_tokens: 512,
|
||||
threads: (cpu_count - 1).max(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InferenceEngine {
|
||||
model: LlamaModel,
|
||||
backend: LlamaBackend,
|
||||
params: InferenceParams,
|
||||
}
|
||||
|
||||
impl InferenceEngine {
|
||||
pub fn load<P: AsRef<Path>>(
|
||||
model_path: P,
|
||||
params: InferenceParams,
|
||||
debug: bool,
|
||||
) -> Result<Self, String> {
|
||||
// Configure logging - suppress unless debug mode
|
||||
let log_options = LogOptions::default().with_logs_enabled(debug);
|
||||
send_logs_to_tracing(log_options);
|
||||
|
||||
let backend =
|
||||
LlamaBackend::init().map_err(|e| format!("Failed to init backend: {}", e))?;
|
||||
|
||||
let model_params = LlamaModelParams::default();
|
||||
|
||||
let model = LlamaModel::load_from_file(&backend, model_path, &model_params)
|
||||
.map_err(|e| format!("Failed to load model: {}", e))?;
|
||||
|
||||
Ok(Self {
|
||||
model,
|
||||
backend,
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate(&self, prompt: &str) -> Result<String, String> {
|
||||
self.generate_internal(prompt, true)
|
||||
}
|
||||
|
||||
/// Generate without printing to stdout (for internal use like session naming)
|
||||
pub fn generate_silent(&self, prompt: &str, max_tokens: u32) -> Result<String, String> {
|
||||
let ctx_params = LlamaContextParams::default()
|
||||
.with_n_ctx(NonZeroU32::new(self.params.ctx_size))
|
||||
.with_n_threads(self.params.threads)
|
||||
.with_n_threads_batch(self.params.threads);
|
||||
|
||||
let mut ctx = self
|
||||
.model
|
||||
.new_context(&self.backend, ctx_params)
|
||||
.map_err(|e| format!("Failed to create context: {}", e))?;
|
||||
|
||||
let tokens = self
|
||||
.model
|
||||
.str_to_token(prompt, AddBos::Always)
|
||||
.map_err(|e| format!("Failed to tokenize: {}", e))?;
|
||||
|
||||
if tokens.is_empty() {
|
||||
return Err("Empty prompt".to_string());
|
||||
}
|
||||
|
||||
let mut batch = LlamaBatch::new(self.params.ctx_size as usize, 1);
|
||||
|
||||
for (i, token) in tokens.iter().enumerate() {
|
||||
let is_last = i == tokens.len() - 1;
|
||||
batch
|
||||
.add(*token, i as i32, &[0], is_last)
|
||||
.map_err(|_| "Failed to add token to batch")?;
|
||||
}
|
||||
|
||||
ctx.decode(&mut batch)
|
||||
.map_err(|e| format!("Initial decode failed: {}", e))?;
|
||||
|
||||
let mut sampler = LlamaSampler::chain_simple([
|
||||
LlamaSampler::temp(self.params.temperature),
|
||||
LlamaSampler::top_p(self.params.top_p, 1),
|
||||
LlamaSampler::dist(0),
|
||||
]);
|
||||
|
||||
let mut output = String::new();
|
||||
let mut n_cur = tokens.len();
|
||||
|
||||
for _ in 0..max_tokens {
|
||||
let new_token = sampler.sample(&ctx, -1);
|
||||
|
||||
if self.model.is_eog_token(new_token) {
|
||||
break;
|
||||
}
|
||||
|
||||
let token_str = self
|
||||
.model
|
||||
.token_to_str(new_token, Special::Tokenize)
|
||||
.map_err(|e| format!("Token decode error: {}", e))?;
|
||||
|
||||
output.push_str(&token_str);
|
||||
|
||||
batch.clear();
|
||||
batch
|
||||
.add(new_token, n_cur as i32, &[0], true)
|
||||
.map_err(|_| "Failed to add token")?;
|
||||
|
||||
ctx.decode(&mut batch)
|
||||
.map_err(|e| format!("Decode failed: {}", e))?;
|
||||
|
||||
n_cur += 1;
|
||||
}
|
||||
|
||||
Ok(output.trim().to_string())
|
||||
}
|
||||
|
||||
fn generate_internal(&self, prompt: &str, stream: bool) -> Result<String, String> {
|
||||
let ctx_params = LlamaContextParams::default()
|
||||
.with_n_ctx(NonZeroU32::new(self.params.ctx_size))
|
||||
.with_n_threads(self.params.threads)
|
||||
.with_n_threads_batch(self.params.threads);
|
||||
|
||||
let mut ctx = self
|
||||
.model
|
||||
.new_context(&self.backend, ctx_params)
|
||||
.map_err(|e| format!("Failed to create context: {}", e))?;
|
||||
|
||||
let tokens = self
|
||||
.model
|
||||
.str_to_token(prompt, AddBos::Always)
|
||||
.map_err(|e| format!("Failed to tokenize: {}", e))?;
|
||||
|
||||
if tokens.is_empty() {
|
||||
return Err("Empty prompt".to_string());
|
||||
}
|
||||
|
||||
let mut batch = LlamaBatch::new(self.params.ctx_size as usize, 1);
|
||||
|
||||
for (i, token) in tokens.iter().enumerate() {
|
||||
let is_last = i == tokens.len() - 1;
|
||||
batch
|
||||
.add(*token, i as i32, &[0], is_last)
|
||||
.map_err(|_| "Failed to add token to batch")?;
|
||||
}
|
||||
|
||||
ctx.decode(&mut batch)
|
||||
.map_err(|e| format!("Initial decode failed: {}", e))?;
|
||||
|
||||
let mut sampler = LlamaSampler::chain_simple([
|
||||
LlamaSampler::temp(self.params.temperature),
|
||||
LlamaSampler::top_p(self.params.top_p, 1),
|
||||
LlamaSampler::dist(0),
|
||||
]);
|
||||
|
||||
let mut output = String::new();
|
||||
let mut n_cur = tokens.len();
|
||||
|
||||
if stream {
|
||||
print!("\n");
|
||||
io::stdout().flush().ok();
|
||||
}
|
||||
|
||||
for _ in 0..self.params.max_tokens {
|
||||
let new_token = sampler.sample(&ctx, -1);
|
||||
|
||||
if self.model.is_eog_token(new_token) {
|
||||
break;
|
||||
}
|
||||
|
||||
let token_str = self
|
||||
.model
|
||||
.token_to_str(new_token, Special::Tokenize)
|
||||
.map_err(|e| format!("Token decode error: {}", e))?;
|
||||
|
||||
if stream {
|
||||
print!("{}", token_str);
|
||||
io::stdout().flush().ok();
|
||||
}
|
||||
output.push_str(&token_str);
|
||||
|
||||
batch.clear();
|
||||
batch
|
||||
.add(new_token, n_cur as i32, &[0], true)
|
||||
.map_err(|_| "Failed to add token")?;
|
||||
|
||||
ctx.decode(&mut batch)
|
||||
.map_err(|e| format!("Decode failed: {}", e))?;
|
||||
|
||||
n_cur += 1;
|
||||
}
|
||||
|
||||
if stream {
|
||||
println!("\n");
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
96
src/infrastructure/init.rs
Normal file
96
src/infrastructure/init.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
use crate::infrastructure::db;
|
||||
use crate::infrastructure::inference::{InferenceEngine, InferenceParams};
|
||||
use crate::infrastructure::model_registry;
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Configuration for infrastructure initialization
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InfrastructureConfig {
|
||||
pub debug: bool,
|
||||
pub app_name: String,
|
||||
}
|
||||
|
||||
impl Default for InfrastructureConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
debug: false,
|
||||
app_name: "drastis".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of infrastructure initialization
|
||||
pub struct InfrastructureComponents {
|
||||
pub connection: Connection,
|
||||
pub engine: InferenceEngine,
|
||||
}
|
||||
|
||||
/// Infrastructure initialization service
|
||||
/// Handles database setup, model loading, and inference engine initialization
|
||||
pub struct InfrastructureInitializer {
|
||||
config: InfrastructureConfig,
|
||||
}
|
||||
|
||||
impl InfrastructureInitializer {
|
||||
/// Create a new infrastructure initializer
|
||||
pub fn new(config: InfrastructureConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Create with debug flag
|
||||
pub fn with_debug(debug: bool) -> Self {
|
||||
Self::new(InfrastructureConfig {
|
||||
debug,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Initialize all infrastructure components
|
||||
/// 1. Database connection
|
||||
/// 2. Model scanning and selection
|
||||
/// 3. Inference engine loading
|
||||
pub fn initialize(&self) -> Result<InfrastructureComponents, String> {
|
||||
// 1. Initialize database
|
||||
log::info!("Initializing database...");
|
||||
let connection = db::init_db(&self.config.app_name, self.config.debug)?;
|
||||
|
||||
// 2. Scan and select model
|
||||
log::info!("Scanning for GGUF models...");
|
||||
|
||||
let models = model_registry::scan_models().map_err(|e| e.to_string())?;
|
||||
let selected = model_registry::select_model(models).map_err(|e| e.to_string())?;
|
||||
|
||||
// 3. Load inference engine
|
||||
log::info!("Loading model...");
|
||||
|
||||
let params = InferenceParams::default();
|
||||
log::debug!(
|
||||
"Parameters: ctx={}, temp={}, top_p={}, max_tokens={}, threads={}",
|
||||
params.ctx_size, params.temperature, params.top_p, params.max_tokens, params.threads
|
||||
);
|
||||
|
||||
let engine = InferenceEngine::load(&selected, params, self.config.debug)?;
|
||||
|
||||
log::info!("Infrastructure initialized successfully.");
|
||||
|
||||
Ok(InfrastructureComponents {
|
||||
connection,
|
||||
engine,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_infrastructure_config() {
|
||||
let config = InfrastructureConfig::default();
|
||||
assert!(!config.debug);
|
||||
assert_eq!(config.app_name, "drastis");
|
||||
|
||||
let initializer = InfrastructureInitializer::with_debug(true);
|
||||
assert!(initializer.config.debug);
|
||||
}
|
||||
}
|
||||
9
src/infrastructure/mod.rs
Normal file
9
src/infrastructure/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
pub mod db;
|
||||
pub mod inference;
|
||||
pub mod init;
|
||||
pub mod model_registry;
|
||||
|
||||
pub use db::*;
|
||||
pub use inference::*;
|
||||
pub use init::*;
|
||||
pub use model_registry::*;
|
||||
75
src/infrastructure/model_registry.rs
Normal file
75
src/infrastructure/model_registry.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const MODELS_DIR: &str = "./models";
|
||||
|
||||
pub fn scan_models() -> io::Result<Vec<PathBuf>> {
|
||||
let models_path = PathBuf::from(MODELS_DIR);
|
||||
|
||||
if !models_path.exists() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("Models directory '{}' not found", MODELS_DIR),
|
||||
));
|
||||
}
|
||||
|
||||
let mut gguf_files: Vec<PathBuf> = fs::read_dir(&models_path)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.extension()
|
||||
.map(|ext| ext.to_string_lossy().to_lowercase() == "gguf")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
gguf_files.sort();
|
||||
Ok(gguf_files)
|
||||
}
|
||||
|
||||
pub fn select_model(models: Vec<PathBuf>) -> io::Result<PathBuf> {
|
||||
match models.len() {
|
||||
0 => Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"No GGUF models found in ./models/",
|
||||
)),
|
||||
1 => {
|
||||
let model = &models[0];
|
||||
println!(
|
||||
"Auto-selected: {}",
|
||||
model.file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
Ok(model.clone())
|
||||
}
|
||||
_ => prompt_user_selection(&models),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_user_selection(models: &[PathBuf]) -> io::Result<PathBuf> {
|
||||
println!("Available models:");
|
||||
for (i, model) in models.iter().enumerate() {
|
||||
println!(
|
||||
" [{}] {}",
|
||||
i + 1,
|
||||
model.file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
}
|
||||
|
||||
loop {
|
||||
print!("Select model (1-{}): ", models.len());
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
|
||||
match input.trim().parse::<usize>() {
|
||||
Ok(n) if n >= 1 && n <= models.len() => {
|
||||
return Ok(models[n - 1].clone());
|
||||
}
|
||||
_ => {
|
||||
println!("Invalid selection. Please enter a number between 1 and {}.", models.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/lib.rs
Normal file
9
src/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
pub mod utils;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
pub mod repository;
|
||||
|
||||
pub use domain::*;
|
||||
pub use infrastructure::*;
|
||||
pub use repository::*;
|
||||
pub use utils::*;
|
||||
167
src/main.rs
Normal file
167
src/main.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
mod utils;
|
||||
mod domain;
|
||||
mod infrastructure;
|
||||
mod repository;
|
||||
|
||||
use clap::Parser;
|
||||
use domain::{StartupService, SessionService, Session, CancellationToken};
|
||||
use infrastructure::InfrastructureInitializer;
|
||||
use rustyline::DefaultEditor;
|
||||
use rustyline::KeyEvent;
|
||||
use rustyline::Cmd;
|
||||
use utils::{handle_readline_error, Action};
|
||||
use std::env;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "drastis")]
|
||||
#[command(about = "Local LLM inference CLI", long_about = None)]
|
||||
struct Args {
|
||||
/// Enable debug output from llama.cpp
|
||||
#[arg(short, long)]
|
||||
debug: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
if let Err(e) = run(args.debug) {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(debug: bool) -> Result<(), String> {
|
||||
// Initialize logging
|
||||
{
|
||||
let mut builder = env_logger::Builder::from_default_env();
|
||||
|
||||
// Our crate logs: respect debug flag
|
||||
if debug {
|
||||
builder.filter_level(log::LevelFilter::Debug);
|
||||
} else {
|
||||
builder.filter_level(log::LevelFilter::Info);
|
||||
}
|
||||
|
||||
// Always silence very chatty deps like rustyline below DEBUG
|
||||
builder.filter_module("rustyline", log::LevelFilter::Info);
|
||||
|
||||
builder.init();
|
||||
}
|
||||
|
||||
// Initialize infrastructure components
|
||||
let infrastructure_initializer = InfrastructureInitializer::with_debug(debug);
|
||||
let infrastructure = infrastructure_initializer.initialize()?;
|
||||
|
||||
println!("Application initialized. Type :q or press Ctrl+Q to exit.\n");
|
||||
|
||||
// Start the REPL with infrastructure components
|
||||
repl(infrastructure.connection, infrastructure.engine, debug)
|
||||
}
|
||||
|
||||
|
||||
fn repl(
|
||||
connection: rusqlite::Connection,
|
||||
engine: infrastructure::InferenceEngine,
|
||||
debug: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut rl = DefaultEditor::new().map_err(|e| e.to_string())?;
|
||||
|
||||
// Bind Ctrl+Q to EOF (quit)
|
||||
rl.bind_sequence(KeyEvent::ctrl('q'), Cmd::EndOfFile);
|
||||
|
||||
let session_service = SessionService::new();
|
||||
let startup_service = StartupService::with_debug(debug);
|
||||
let mut current_session: Option<Session> = None;
|
||||
|
||||
// Set up cancellation token for Ctrl+C handling
|
||||
let cancel_token = CancellationToken::new();
|
||||
let cancel_token_handler = cancel_token.clone();
|
||||
|
||||
ctrlc::set_handler(move || {
|
||||
cancel_token_handler.cancel();
|
||||
}).map_err(|e| format!("Failed to set Ctrl+C handler: {}", e))?;
|
||||
|
||||
// Load history if exists
|
||||
let history_path = dirs_home().join(".drastis_history");
|
||||
let _ = rl.load_history(&history_path);
|
||||
|
||||
loop {
|
||||
// Reset cancellation token before each prompt
|
||||
cancel_token.reset();
|
||||
|
||||
match rl.readline("You> ") {
|
||||
Ok(line) => {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle :q and :quit commands
|
||||
if trimmed == ":q" || trimmed == ":quit" {
|
||||
println!("Goodbye!");
|
||||
break;
|
||||
}
|
||||
|
||||
let _ = rl.add_history_entry(&line);
|
||||
|
||||
// Handle session creation or restart
|
||||
if current_session.is_none() {
|
||||
// Create new session (without requests - session_service will create them)
|
||||
current_session = Some(startup_service.start(&connection, &engine, trimmed)?);
|
||||
}
|
||||
|
||||
let session = current_session.as_ref().unwrap();
|
||||
|
||||
// Run the session service (creates request, runs workflow, updates result)
|
||||
match session_service.run(session, trimmed, &engine, &cancel_token, &connection) {
|
||||
Ok(chain) => {
|
||||
let finish_message = chain.get_finish_message();
|
||||
println!("Ok> {}", finish_message);
|
||||
}
|
||||
Err(domain::session::ServiceError::Workflow(domain::workflow::Error::Cancelled)) => {
|
||||
println!("\nInterrupted.");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Reload session to get updated requests
|
||||
current_session = Some(startup_service.load_session(&connection, session.id())?);
|
||||
}
|
||||
Err(err) => {
|
||||
// Handle keyboard shortcuts (Ctrl+C, Ctrl+Q, Ctrl+D)
|
||||
match handle_readline_error(err)? {
|
||||
Action::Cancel => {
|
||||
// Ctrl+C - cancel current operation
|
||||
println!("^C");
|
||||
continue;
|
||||
}
|
||||
Action::Quit => {
|
||||
// Ctrl+Q or Ctrl+D - quit the application
|
||||
println!("Goodbye!");
|
||||
break;
|
||||
}
|
||||
Action::Continue => {
|
||||
// Should not happen, but continue if it does
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save history
|
||||
let _ = rl.save_history(&history_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn dirs_home() -> std::path::PathBuf {
|
||||
env::var("HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("."))
|
||||
}
|
||||
|
||||
56
src/repository/meta.rs
Normal file
56
src/repository/meta.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
use rusqlite::{Connection, params};
|
||||
|
||||
pub struct MetaRepository<'a> {
|
||||
conn: &'a Connection,
|
||||
}
|
||||
|
||||
impl<'a> MetaRepository<'a> {
|
||||
pub fn new(conn: &'a Connection) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Result<Option<String>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT value FROM meta WHERE key = ?")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let result = stmt
|
||||
.query_row(params![key], |row| row.get(0))
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, value: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)",
|
||||
params![key, value],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_schema_version(&self) -> Result<i32, String> {
|
||||
self.get("schema_version")?
|
||||
.ok_or_else(|| "schema_version not found".to_string())?
|
||||
.parse()
|
||||
.map_err(|e: std::num::ParseIntError| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
trait OptionalExt<T> {
|
||||
fn optional(self) -> Result<Option<T>, rusqlite::Error>;
|
||||
}
|
||||
|
||||
impl<T> OptionalExt<T> for Result<T, rusqlite::Error> {
|
||||
fn optional(self) -> Result<Option<T>, rusqlite::Error> {
|
||||
match self {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/repository/mod.rs
Normal file
9
src/repository/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
pub mod meta;
|
||||
pub mod projects;
|
||||
pub mod sessions;
|
||||
pub mod session_requests;
|
||||
|
||||
pub use meta::MetaRepository;
|
||||
pub use projects::{ProjectRow, ProjectsRepository};
|
||||
pub use sessions::{SessionRow, SessionsRepository};
|
||||
pub use session_requests::{SessionRequestRow, SessionRequestsRepository};
|
||||
108
src/repository/projects.rs
Normal file
108
src/repository/projects.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
use rusqlite::{Connection, params, Row};
|
||||
|
||||
/// Raw database row for projects table
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub created_at: String,
|
||||
pub session_count: i64,
|
||||
}
|
||||
|
||||
impl ProjectRow {
|
||||
fn from_row(row: &Row) -> Result<Self, rusqlite::Error> {
|
||||
Ok(Self {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
session_count: row.get(3)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProjectsRepository<'a> {
|
||||
conn: &'a Connection,
|
||||
}
|
||||
|
||||
impl<'a> ProjectsRepository<'a> {
|
||||
pub fn new(conn: &'a Connection) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
|
||||
pub fn find_by_name(&self, name: &str) -> Result<Option<ProjectRow>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT id, name, created_at, session_count FROM projects WHERE name = ?")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let result = stmt
|
||||
.query_row(params![name], ProjectRow::from_row)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn create(&self, name: &str) -> Result<ProjectRow, String> {
|
||||
let created_at = chrono_now();
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO projects (name, created_at, session_count) VALUES (?, ?, 0)",
|
||||
params![name, created_at],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let id = self.conn.last_insert_rowid();
|
||||
|
||||
Ok(ProjectRow {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
created_at,
|
||||
session_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn increment_session_count(&self, project_id: i64) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE projects SET session_count = session_count + 1 WHERE id = ?",
|
||||
params![project_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_or_create(&self, name: &str) -> Result<(ProjectRow, bool), String> {
|
||||
if let Some(row) = self.find_by_name(name)? {
|
||||
Ok((row, false))
|
||||
} else {
|
||||
let row = self.create(name)?;
|
||||
Ok((row, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chrono_now() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
// ISO 8601 format approximation
|
||||
let secs = duration.as_secs();
|
||||
format!("{}", secs)
|
||||
}
|
||||
|
||||
trait OptionalExt<T> {
|
||||
fn optional(self) -> Result<Option<T>, rusqlite::Error>;
|
||||
}
|
||||
|
||||
impl<T> OptionalExt<T> for Result<T, rusqlite::Error> {
|
||||
fn optional(self) -> Result<Option<T>, rusqlite::Error> {
|
||||
match self {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
240
src/repository/session_requests.rs
Normal file
240
src/repository/session_requests.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
use rusqlite::{Connection, params, Row};
|
||||
|
||||
/// Raw database row for session_requests table
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRequestRow {
|
||||
pub id: i64,
|
||||
pub session_id: i64,
|
||||
pub prompt: String,
|
||||
pub result_summary: Option<String>,
|
||||
pub steps_log: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl SessionRequestRow {
|
||||
fn from_row(row: &Row) -> Result<Self, rusqlite::Error> {
|
||||
Ok(Self {
|
||||
id: row.get(0)?,
|
||||
session_id: row.get(1)?,
|
||||
prompt: row.get(2)?,
|
||||
result_summary: row.get(3)?,
|
||||
steps_log: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionRequestsRepository<'a> {
|
||||
conn: &'a Connection,
|
||||
}
|
||||
|
||||
impl<'a> SessionRequestsRepository<'a> {
|
||||
pub fn new(conn: &'a Connection) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
|
||||
/// Create a new session request
|
||||
pub fn create(&self, session_id: i64, prompt: &str) -> Result<SessionRequestRow, String> {
|
||||
let created_at = chrono_now();
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO session_requests (session_id, prompt, created_at) VALUES (?, ?, ?)",
|
||||
params![session_id, prompt, created_at],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let id = self.conn.last_insert_rowid();
|
||||
|
||||
Ok(SessionRequestRow {
|
||||
id,
|
||||
session_id,
|
||||
prompt: prompt.to_string(),
|
||||
result_summary: None,
|
||||
steps_log: None,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update the result summary for a request
|
||||
pub fn update_result(&self, request_id: i64, result_summary: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE session_requests SET result_summary = ? WHERE id = ?",
|
||||
params![result_summary, request_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the steps log for a request
|
||||
pub fn update_steps_log(&self, request_id: i64, steps_log: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE session_requests SET steps_log = ? WHERE id = ?",
|
||||
params![steps_log, request_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find a request by ID
|
||||
pub fn find_by_id(&self, id: i64) -> Result<Option<SessionRequestRow>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT id, session_id, prompt, result_summary, steps_log, created_at FROM session_requests WHERE id = ?")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let result = stmt
|
||||
.query_row(params![id], SessionRequestRow::from_row)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Find all requests for a session, ordered by creation time
|
||||
pub fn find_by_session(&self, session_id: i64) -> Result<Vec<SessionRequestRow>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT id, session_id, prompt, result_summary, steps_log, created_at FROM session_requests WHERE session_id = ? ORDER BY created_at ASC")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![session_id], SessionRequestRow::from_row)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut requests = Vec::new();
|
||||
for row in rows {
|
||||
requests.push(row.map_err(|e| e.to_string())?);
|
||||
}
|
||||
|
||||
Ok(requests)
|
||||
}
|
||||
|
||||
/// Get the latest request for a session (most recent)
|
||||
pub fn find_latest_by_session(&self, session_id: i64) -> Result<Option<SessionRequestRow>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT id, session_id, prompt, result_summary, steps_log, created_at FROM session_requests WHERE session_id = ? ORDER BY created_at DESC LIMIT 1")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let result = stmt
|
||||
.query_row(params![session_id], SessionRequestRow::from_row)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Delete all requests for a session
|
||||
pub fn delete_by_session(&self, session_id: i64) -> Result<usize, String> {
|
||||
let affected = self
|
||||
.conn
|
||||
.execute("DELETE FROM session_requests WHERE session_id = ?", params![session_id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(affected)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get current timestamp as string
|
||||
fn chrono_now() -> String {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// Extension trait for Option<T> to handle database results
|
||||
trait OptionalExt<T> {
|
||||
fn optional(self) -> Result<Option<T>, rusqlite::Error>;
|
||||
}
|
||||
|
||||
impl<T> OptionalExt<T> for Result<T, rusqlite::Error> {
|
||||
fn optional(self) -> Result<Option<T>, rusqlite::Error> {
|
||||
match self {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
|
||||
// Create the tables
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE session_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
result_summary TEXT,
|
||||
steps_log TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
||||
);"
|
||||
).unwrap();
|
||||
|
||||
// Insert a test session
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (project_id, name, created_at) VALUES (1, 'Test Session', '1234567890')",
|
||||
[]
|
||||
).unwrap();
|
||||
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_request() {
|
||||
let conn = setup_test_db();
|
||||
let repo = SessionRequestsRepository::new(&conn);
|
||||
|
||||
let request = repo.create(1, "Test prompt").unwrap();
|
||||
assert_eq!(request.session_id, 1);
|
||||
assert_eq!(request.prompt, "Test prompt");
|
||||
assert!(request.result_summary.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_result() {
|
||||
let conn = setup_test_db();
|
||||
let repo = SessionRequestsRepository::new(&conn);
|
||||
|
||||
let request = repo.create(1, "Test prompt").unwrap();
|
||||
repo.update_result(request.id, "Test result").unwrap();
|
||||
|
||||
let updated = repo.find_by_id(request.id).unwrap().unwrap();
|
||||
assert_eq!(updated.result_summary, Some("Test result".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_by_session() {
|
||||
let conn = setup_test_db();
|
||||
let repo = SessionRequestsRepository::new(&conn);
|
||||
|
||||
repo.create(1, "First prompt").unwrap();
|
||||
repo.create(1, "Second prompt").unwrap();
|
||||
|
||||
let requests = repo.find_by_session(1).unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].prompt, "First prompt");
|
||||
assert_eq!(requests[1].prompt, "Second prompt");
|
||||
}
|
||||
}
|
||||
108
src/repository/sessions.rs
Normal file
108
src/repository/sessions.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
use rusqlite::{Connection, params, Row};
|
||||
|
||||
/// Raw database row for sessions table
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRow {
|
||||
pub id: i64,
|
||||
pub project_id: i64,
|
||||
pub name: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl SessionRow {
|
||||
fn from_row(row: &Row) -> Result<Self, rusqlite::Error> {
|
||||
Ok(Self {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionsRepository<'a> {
|
||||
conn: &'a Connection,
|
||||
}
|
||||
|
||||
impl<'a> SessionsRepository<'a> {
|
||||
pub fn new(conn: &'a Connection) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
|
||||
pub fn create(&self, project_id: i64, name: &str) -> Result<SessionRow, String> {
|
||||
let created_at = chrono_now();
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO sessions (project_id, name, created_at) VALUES (?, ?, ?)",
|
||||
params![project_id, name, created_at],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let id = self.conn.last_insert_rowid();
|
||||
|
||||
Ok(SessionRow {
|
||||
id,
|
||||
project_id,
|
||||
name: name.to_string(),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_by_id(&self, id: i64) -> Result<Option<SessionRow>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT id, project_id, name, created_at FROM sessions WHERE id = ?")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let result = stmt
|
||||
.query_row(params![id], SessionRow::from_row)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn find_by_project(&self, project_id: i64) -> Result<Vec<SessionRow>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT id, project_id, name, created_at FROM sessions WHERE project_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![project_id], SessionRow::from_row)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for row in rows {
|
||||
sessions.push(row.map_err(|e| e.to_string())?);
|
||||
}
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
}
|
||||
|
||||
fn chrono_now() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = duration.as_secs();
|
||||
format!("{}", secs)
|
||||
}
|
||||
|
||||
trait OptionalExt<T> {
|
||||
fn optional(self) -> Result<Option<T>, rusqlite::Error>;
|
||||
}
|
||||
|
||||
impl<T> OptionalExt<T> for Result<T, rusqlite::Error> {
|
||||
fn optional(self) -> Result<Option<T>, rusqlite::Error> {
|
||||
match self {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
246
src/utils/io/file_operations.rs
Normal file
246
src/utils/io/file_operations.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FileInsertError {
|
||||
FileNotFound(String),
|
||||
InvalidLineNumber(String),
|
||||
IoError(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FileRemoveError {
|
||||
FileNotFound(String),
|
||||
InvalidLineNumber(String),
|
||||
InvalidCount(String),
|
||||
IoError(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FileReplaceError {
|
||||
FileNotFound(String),
|
||||
InvalidLineNumber(String),
|
||||
InvalidRange(String),
|
||||
IoError(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FileInsertError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
FileInsertError::FileNotFound(path) => write!(f, "File not found: {}", path),
|
||||
FileInsertError::InvalidLineNumber(msg) => write!(f, "Invalid line number: {}", msg),
|
||||
FileInsertError::IoError(msg) => write!(f, "IO error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FileRemoveError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
FileRemoveError::FileNotFound(path) => write!(f, "File not found: {}", path),
|
||||
FileRemoveError::InvalidLineNumber(msg) => write!(f, "Invalid line number: {}", msg),
|
||||
FileRemoveError::InvalidCount(msg) => write!(f, "Invalid count: {}", msg),
|
||||
FileRemoveError::IoError(msg) => write!(f, "IO error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FileReplaceError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
FileReplaceError::FileNotFound(path) => write!(f, "File not found: {}", path),
|
||||
FileReplaceError::InvalidLineNumber(msg) => write!(f, "Invalid line number: {}", msg),
|
||||
FileReplaceError::InvalidRange(msg) => write!(f, "Invalid range: {}", msg),
|
||||
FileReplaceError::IoError(msg) => write!(f, "IO error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert content at a specific line in a file.
|
||||
/// Line numbers are 1-based.
|
||||
pub fn insert_content(file_path: &str, target_line: usize, content: &str) -> Result<(), FileInsertError> {
|
||||
if !Path::new(file_path).exists() {
|
||||
return Err(FileInsertError::FileNotFound(file_path.to_string()));
|
||||
}
|
||||
|
||||
if target_line == 0 {
|
||||
return Err(FileInsertError::InvalidLineNumber("Line numbers must be 1-based".to_string()));
|
||||
}
|
||||
|
||||
let file_content = fs::read_to_string(file_path)
|
||||
.map_err(|e| FileInsertError::IoError(format!("Failed to read file: {}", e)))?;
|
||||
|
||||
let mut lines: Vec<String> = file_content.lines().map(|s| s.to_string()).collect();
|
||||
|
||||
// Convert 1-based line number to 0-based index
|
||||
let insert_index = if target_line > lines.len() {
|
||||
lines.len()
|
||||
} else {
|
||||
target_line - 1
|
||||
};
|
||||
|
||||
// Split the content to insert into lines
|
||||
let content_lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
|
||||
|
||||
// Insert the content
|
||||
for (i, line) in content_lines.iter().enumerate() {
|
||||
lines.insert(insert_index + i, line.clone());
|
||||
}
|
||||
|
||||
let new_content = lines.join("\n");
|
||||
fs::write(file_path, new_content)
|
||||
.map_err(|e| FileInsertError::IoError(format!("Failed to write file: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a specified number of lines starting from a target line.
|
||||
/// Line numbers are 1-based.
|
||||
pub fn remove_lines(file_path: &str, target_line: usize, count: usize) -> Result<(), FileRemoveError> {
|
||||
if !Path::new(file_path).exists() {
|
||||
return Err(FileRemoveError::FileNotFound(file_path.to_string()));
|
||||
}
|
||||
|
||||
if target_line == 0 {
|
||||
return Err(FileRemoveError::InvalidLineNumber("Line numbers must be 1-based".to_string()));
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return Err(FileRemoveError::InvalidCount("Count must be greater than 0".to_string()));
|
||||
}
|
||||
|
||||
let file_content = fs::read_to_string(file_path)
|
||||
.map_err(|e| FileRemoveError::IoError(format!("Failed to read file: {}", e)))?;
|
||||
|
||||
let mut lines: Vec<String> = file_content.lines().map(|s| s.to_string()).collect();
|
||||
|
||||
// Convert 1-based line number to 0-based index
|
||||
let start_index = if target_line > lines.len() {
|
||||
return Err(FileRemoveError::InvalidLineNumber(
|
||||
format!("Line {} is beyond file length ({})", target_line, lines.len())
|
||||
));
|
||||
} else {
|
||||
target_line - 1
|
||||
};
|
||||
|
||||
let end_index = (start_index + count).min(lines.len());
|
||||
|
||||
// Remove the lines
|
||||
lines.drain(start_index..end_index);
|
||||
|
||||
let new_content = lines.join("\n");
|
||||
fs::write(file_path, new_content)
|
||||
.map_err(|e| FileRemoveError::IoError(format!("Failed to write file: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace content between start_line and end_line (inclusive) with new content.
|
||||
/// Line numbers are 1-based.
|
||||
pub fn replace_lines(file_path: &str, start_line: usize, end_line: usize, content: &str) -> Result<(), FileReplaceError> {
|
||||
if !Path::new(file_path).exists() {
|
||||
return Err(FileReplaceError::FileNotFound(file_path.to_string()));
|
||||
}
|
||||
|
||||
if start_line == 0 || end_line == 0 {
|
||||
return Err(FileReplaceError::InvalidLineNumber("Line numbers must be 1-based".to_string()));
|
||||
}
|
||||
|
||||
if start_line > end_line {
|
||||
return Err(FileReplaceError::InvalidRange(
|
||||
format!("Start line ({}) must be <= end line ({})", start_line, end_line)
|
||||
));
|
||||
}
|
||||
|
||||
let file_content = fs::read_to_string(file_path)
|
||||
.map_err(|e| FileReplaceError::IoError(format!("Failed to read file: {}", e)))?;
|
||||
|
||||
let mut lines: Vec<String> = file_content.lines().map(|s| s.to_string()).collect();
|
||||
|
||||
// Convert 1-based line numbers to 0-based indices
|
||||
let start_index = if start_line > lines.len() {
|
||||
return Err(FileReplaceError::InvalidLineNumber(
|
||||
format!("Start line {} is beyond file length ({})", start_line, lines.len())
|
||||
));
|
||||
} else {
|
||||
start_line - 1
|
||||
};
|
||||
|
||||
let end_index = if end_line > lines.len() {
|
||||
lines.len()
|
||||
} else {
|
||||
end_line
|
||||
};
|
||||
|
||||
// Split the replacement content into lines
|
||||
let replacement_lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
|
||||
|
||||
// Remove the old lines and insert the new ones
|
||||
lines.drain(start_index..end_index);
|
||||
for (i, line) in replacement_lines.iter().enumerate() {
|
||||
lines.insert(start_index + i, line.clone());
|
||||
}
|
||||
|
||||
let new_content = lines.join("\n");
|
||||
fs::write(file_path, new_content)
|
||||
.map_err(|e| FileReplaceError::IoError(format!("Failed to write file: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
use std::fs;
|
||||
|
||||
fn create_test_file(dir: &TempDir, name: &str, content: &str) -> String {
|
||||
let file_path = dir.path().join(name);
|
||||
fs::write(&file_path, content).unwrap();
|
||||
file_path.to_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = create_test_file(&dir, "test.txt", "line 1\nline 2\nline 3");
|
||||
|
||||
insert_content(&file_path, 2, "inserted line\nanother inserted line").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&file_path).unwrap();
|
||||
assert_eq!(content, "line 1\ninserted line\nanother inserted line\nline 2\nline 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = create_test_file(&dir, "test.txt", "line 1\nline 2\nline 3\nline 4\nline 5");
|
||||
|
||||
remove_lines(&file_path, 2, 2).unwrap();
|
||||
|
||||
let content = fs::read_to_string(&file_path).unwrap();
|
||||
assert_eq!(content, "line 1\nline 4\nline 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = create_test_file(&dir, "test.txt", "line 1\nline 2\nline 3\nline 4");
|
||||
|
||||
replace_lines(&file_path, 2, 3, "new line 2\nnew line 3").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&file_path).unwrap();
|
||||
assert_eq!(content, "line 1\nnew line 2\nnew line 3\nline 4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_at_end() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = create_test_file(&dir, "test.txt", "line 1\nline 2");
|
||||
|
||||
insert_content(&file_path, 10, "line 3").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&file_path).unwrap();
|
||||
assert_eq!(content, "line 1\nline 2\nline 3");
|
||||
}
|
||||
}
|
||||
3
src/utils/io/mod.rs
Normal file
3
src/utils/io/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod file_operations;
|
||||
|
||||
pub use file_operations::{FileInsertError, FileRemoveError, FileReplaceError, insert_content, remove_lines, replace_lines};
|
||||
7
src/utils/mod.rs
Normal file
7
src/utils/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub mod io;
|
||||
pub mod parsing;
|
||||
pub mod shortcuts;
|
||||
|
||||
pub use io::{FileInsertError, FileRemoveError, FileReplaceError, insert_content, remove_lines, replace_lines};
|
||||
pub use parsing::{HeuristicObject, HeuristicParser, Lang, ObjectKind, ParsedObject, ParseResult, TreeSitterParser, UniversalParser};
|
||||
pub use shortcuts::{handle_readline_error, Action};
|
||||
559
src/utils/parsing/heuristics.rs
Normal file
559
src/utils/parsing/heuristics.rs
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
use regex::Regex;
|
||||
use std::path::Path;
|
||||
|
||||
use super::tree_sitter::ObjectKind;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeuristicObject {
|
||||
pub name: String,
|
||||
pub kind: ObjectKind,
|
||||
pub line_start: usize,
|
||||
pub line_end: usize,
|
||||
pub indent_level: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LanguagePatterns {
|
||||
pub name: &'static str,
|
||||
pub extensions: &'static [&'static str],
|
||||
pub patterns: &'static [(&'static str, ObjectKind)],
|
||||
pub block_start: Option<&'static str>,
|
||||
pub block_end: Option<&'static str>,
|
||||
pub indent_based: bool,
|
||||
}
|
||||
|
||||
pub struct HeuristicParser {
|
||||
language_patterns: Vec<LanguagePatterns>,
|
||||
}
|
||||
|
||||
impl HeuristicParser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
language_patterns: Self::default_patterns(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_patterns() -> Vec<LanguagePatterns> {
|
||||
vec![
|
||||
// PHP
|
||||
LanguagePatterns {
|
||||
name: "PHP",
|
||||
extensions: &["php", "phtml", "php3", "php4", "php5", "phps"],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*(?:public|private|protected|static|\s)*\s*function\s+(\w+)\s*\(", ObjectKind::Function),
|
||||
(r"(?m)^\s*(?:abstract\s+)?class\s+(\w+)", ObjectKind::Class),
|
||||
(r"(?m)^\s*interface\s+(\w+)", ObjectKind::Interface),
|
||||
(r"(?m)^\s*trait\s+(\w+)", ObjectKind::Trait),
|
||||
(r"(?m)^\s*(?:final\s+)?enum\s+(\w+)", ObjectKind::Enum),
|
||||
(r"(?m)^\s*const\s+(\w+)\s*=", ObjectKind::Constant),
|
||||
],
|
||||
block_start: Some("{"),
|
||||
block_end: Some("}"),
|
||||
indent_based: false,
|
||||
},
|
||||
// Kotlin
|
||||
LanguagePatterns {
|
||||
name: "Kotlin",
|
||||
extensions: &["kt", "kts"],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*(?:public|private|protected|internal|inline|suspend|\s)*\s*fun\s+(?:<[^>]+>\s*)?(\w+)\s*\(", ObjectKind::Function),
|
||||
(r"(?m)^\s*(?:data\s+|sealed\s+|abstract\s+|open\s+)?class\s+(\w+)", ObjectKind::Class),
|
||||
(r"(?m)^\s*(?:fun\s+)?interface\s+(\w+)", ObjectKind::Interface),
|
||||
(r"(?m)^\s*object\s+(\w+)", ObjectKind::Module),
|
||||
(r"(?m)^\s*enum\s+class\s+(\w+)", ObjectKind::Enum),
|
||||
(r"(?m)^\s*(?:const\s+)?val\s+(\w+)\s*[=:]", ObjectKind::Constant),
|
||||
],
|
||||
block_start: Some("{"),
|
||||
block_end: Some("}"),
|
||||
indent_based: false,
|
||||
},
|
||||
// C#
|
||||
LanguagePatterns {
|
||||
name: "C#",
|
||||
extensions: &["cs"],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*(?:public|private|protected|internal|static|async|virtual|override|abstract|\s)*\s*(?:\w+(?:<[^>]+>)?)\s+(\w+)\s*\([^)]*\)\s*(?:where\s+\w+\s*:\s*\w+\s*)?[{]?", ObjectKind::Function),
|
||||
(r"(?m)^\s*(?:public|private|protected|internal|static|abstract|sealed|partial|\s)*\s*class\s+(\w+)", ObjectKind::Class),
|
||||
(r"(?m)^\s*(?:public|private|protected|internal|\s)*\s*interface\s+(\w+)", ObjectKind::Interface),
|
||||
(r"(?m)^\s*(?:public|private|protected|internal|\s)*\s*struct\s+(\w+)", ObjectKind::Struct),
|
||||
(r"(?m)^\s*(?:public|private|protected|internal|\s)*\s*enum\s+(\w+)", ObjectKind::Enum),
|
||||
(r"(?m)^\s*namespace\s+([\w.]+)", ObjectKind::Module),
|
||||
(r"(?m)^\s*(?:public|private|protected|internal|\s)*\s*const\s+\w+\s+(\w+)\s*=", ObjectKind::Constant),
|
||||
],
|
||||
block_start: Some("{"),
|
||||
block_end: Some("}"),
|
||||
indent_based: false,
|
||||
},
|
||||
// Swift
|
||||
LanguagePatterns {
|
||||
name: "Swift",
|
||||
extensions: &["swift"],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*(?:@\w+\s+)*(?:public|private|internal|fileprivate|open|static|class|override|\s)*\s*func\s+(\w+)\s*[<(]", ObjectKind::Function),
|
||||
(r"(?m)^\s*(?:public|private|internal|fileprivate|open|final|\s)*\s*class\s+(\w+)", ObjectKind::Class),
|
||||
(r"(?m)^\s*(?:public|private|internal|fileprivate|\s)*\s*struct\s+(\w+)", ObjectKind::Struct),
|
||||
(r"(?m)^\s*(?:public|private|internal|fileprivate|\s)*\s*enum\s+(\w+)", ObjectKind::Enum),
|
||||
(r"(?m)^\s*(?:public|private|internal|fileprivate|\s)*\s*protocol\s+(\w+)", ObjectKind::Interface),
|
||||
(r"(?m)^\s*(?:public|private|internal|fileprivate|\s)*\s*extension\s+(\w+)", ObjectKind::Class),
|
||||
],
|
||||
block_start: Some("{"),
|
||||
block_end: Some("}"),
|
||||
indent_based: false,
|
||||
},
|
||||
// Scala
|
||||
LanguagePatterns {
|
||||
name: "Scala",
|
||||
extensions: &["scala", "sc"],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*(?:override\s+)?(?:private|protected|\s)*\s*def\s+(\w+)\s*[(\[]", ObjectKind::Function),
|
||||
(r"(?m)^\s*(?:case\s+)?class\s+(\w+)", ObjectKind::Class),
|
||||
(r"(?m)^\s*trait\s+(\w+)", ObjectKind::Trait),
|
||||
(r"(?m)^\s*object\s+(\w+)", ObjectKind::Module),
|
||||
(r"(?m)^\s*(?:sealed\s+)?enum\s+(\w+)", ObjectKind::Enum),
|
||||
(r"(?m)^\s*val\s+(\w+)\s*[=:]", ObjectKind::Constant),
|
||||
],
|
||||
block_start: Some("{"),
|
||||
block_end: Some("}"),
|
||||
indent_based: false,
|
||||
},
|
||||
// Lua
|
||||
LanguagePatterns {
|
||||
name: "Lua",
|
||||
extensions: &["lua"],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*(?:local\s+)?function\s+(\w+(?:\.\w+)*)\s*\(", ObjectKind::Function),
|
||||
(r"(?m)^\s*(\w+)\s*=\s*function\s*\(", ObjectKind::Function),
|
||||
(r"(?m)^\s*local\s+(\w+)\s*=\s*\{", ObjectKind::Module),
|
||||
],
|
||||
block_start: None,
|
||||
block_end: Some("end"),
|
||||
indent_based: false,
|
||||
},
|
||||
// Perl
|
||||
LanguagePatterns {
|
||||
name: "Perl",
|
||||
extensions: &["pl", "pm", "t"],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*sub\s+(\w+)\s*[{(]?", ObjectKind::Function),
|
||||
(r"(?m)^\s*package\s+([\w:]+)", ObjectKind::Module),
|
||||
],
|
||||
block_start: Some("{"),
|
||||
block_end: Some("}"),
|
||||
indent_based: false,
|
||||
},
|
||||
// R
|
||||
LanguagePatterns {
|
||||
name: "R",
|
||||
extensions: &["r", "R", "rmd", "Rmd"],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*(\w+)\s*<-\s*function\s*\(", ObjectKind::Function),
|
||||
(r"(?m)^\s*(\w+)\s*=\s*function\s*\(", ObjectKind::Function),
|
||||
(r#"(?m)^\s*setClass\s*\(\s*["'](\w+)["']"#, ObjectKind::Class),
|
||||
],
|
||||
block_start: Some("{"),
|
||||
block_end: Some("}"),
|
||||
indent_based: false,
|
||||
},
|
||||
// Haskell
|
||||
LanguagePatterns {
|
||||
name: "Haskell",
|
||||
extensions: &["hs", "lhs"],
|
||||
patterns: &[
|
||||
(r"(?m)^(\w+)\s*::\s*", ObjectKind::Function),
|
||||
(r"(?m)^\s*data\s+(\w+)", ObjectKind::Struct),
|
||||
(r"(?m)^\s*newtype\s+(\w+)", ObjectKind::Struct),
|
||||
(r"(?m)^\s*class\s+(\w+)", ObjectKind::Class),
|
||||
(r"(?m)^\s*instance\s+(\w+)", ObjectKind::Class),
|
||||
(r"(?m)^\s*module\s+([\w.]+)", ObjectKind::Module),
|
||||
],
|
||||
block_start: None,
|
||||
block_end: None,
|
||||
indent_based: true,
|
||||
},
|
||||
// Elixir
|
||||
LanguagePatterns {
|
||||
name: "Elixir",
|
||||
extensions: &["ex", "exs"],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*def\s+(\w+)[(\s]", ObjectKind::Function),
|
||||
(r"(?m)^\s*defp\s+(\w+)[(\s]", ObjectKind::Function),
|
||||
(r"(?m)^\s*defmodule\s+([\w.]+)", ObjectKind::Module),
|
||||
(r"(?m)^\s*defmacro\s+(\w+)", ObjectKind::Function),
|
||||
(r"(?m)^\s*defstruct\s+", ObjectKind::Struct),
|
||||
],
|
||||
block_start: Some("do"),
|
||||
block_end: Some("end"),
|
||||
indent_based: false,
|
||||
},
|
||||
// Clojure
|
||||
LanguagePatterns {
|
||||
name: "Clojure",
|
||||
extensions: &["clj", "cljs", "cljc", "edn"],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*\(defn-?\s+(\w+)", ObjectKind::Function),
|
||||
(r"(?m)^\s*\(defmacro\s+(\w+)", ObjectKind::Function),
|
||||
(r"(?m)^\s*\(defrecord\s+(\w+)", ObjectKind::Struct),
|
||||
(r"(?m)^\s*\(defprotocol\s+(\w+)", ObjectKind::Interface),
|
||||
(r"(?m)^\s*\(ns\s+([\w.-]+)", ObjectKind::Module),
|
||||
],
|
||||
block_start: Some("("),
|
||||
block_end: Some(")"),
|
||||
indent_based: false,
|
||||
},
|
||||
// OCaml
|
||||
LanguagePatterns {
|
||||
name: "OCaml",
|
||||
extensions: &["ml", "mli"],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*let\s+(?:rec\s+)?(\w+)\s*[=:]", ObjectKind::Function),
|
||||
(r"(?m)^\s*type\s+(\w+)", ObjectKind::Struct),
|
||||
(r"(?m)^\s*module\s+(\w+)", ObjectKind::Module),
|
||||
(r"(?m)^\s*class\s+(\w+)", ObjectKind::Class),
|
||||
],
|
||||
block_start: None,
|
||||
block_end: None,
|
||||
indent_based: false,
|
||||
},
|
||||
// Zig
|
||||
LanguagePatterns {
|
||||
name: "Zig",
|
||||
extensions: &["zig"],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*(?:pub\s+)?fn\s+(\w+)\s*\(", ObjectKind::Function),
|
||||
(r"(?m)^\s*(?:pub\s+)?const\s+(\w+)\s*=\s*struct", ObjectKind::Struct),
|
||||
(r"(?m)^\s*(?:pub\s+)?const\s+(\w+)\s*=\s*enum", ObjectKind::Enum),
|
||||
(r"(?m)^\s*(?:pub\s+)?const\s+(\w+)\s*=", ObjectKind::Constant),
|
||||
],
|
||||
block_start: Some("{"),
|
||||
block_end: Some("}"),
|
||||
indent_based: false,
|
||||
},
|
||||
// Dart
|
||||
LanguagePatterns {
|
||||
name: "Dart",
|
||||
extensions: &["dart"],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*(?:static\s+)?(?:\w+(?:<[^>]+>)?)\s+(\w+)\s*\([^)]*\)\s*(?:async\s*)?[{]", ObjectKind::Function),
|
||||
(r"(?m)^\s*(?:abstract\s+)?class\s+(\w+)", ObjectKind::Class),
|
||||
(r"(?m)^\s*enum\s+(\w+)", ObjectKind::Enum),
|
||||
(r"(?m)^\s*mixin\s+(\w+)", ObjectKind::Trait),
|
||||
(r"(?m)^\s*extension\s+(\w+)", ObjectKind::Class),
|
||||
],
|
||||
block_start: Some("{"),
|
||||
block_end: Some("}"),
|
||||
indent_based: false,
|
||||
},
|
||||
// Generic fallback for unknown languages
|
||||
LanguagePatterns {
|
||||
name: "Unknown",
|
||||
extensions: &[],
|
||||
patterns: &[
|
||||
(r"(?m)^\s*(?:pub\s+|public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?(?:fn|func|function|def|sub|proc)\s+(\w+)\s*[(<]", ObjectKind::Function),
|
||||
(r"(?m)^\s*(?:pub\s+|public\s+)?(?:abstract\s+)?(?:final\s+)?class\s+(\w+)", ObjectKind::Class),
|
||||
(r"(?m)^\s*(?:pub\s+|public\s+)?struct\s+(\w+)", ObjectKind::Struct),
|
||||
(r"(?m)^\s*(?:pub\s+|public\s+)?enum\s+(\w+)", ObjectKind::Enum),
|
||||
(r"(?m)^\s*(?:pub\s+|public\s+)?(?:interface|protocol|trait)\s+(\w+)", ObjectKind::Interface),
|
||||
(r"(?m)^\s*(?:pub\s+|public\s+)?module\s+(\w+)", ObjectKind::Module),
|
||||
],
|
||||
block_start: Some("{"),
|
||||
block_end: Some("}"),
|
||||
indent_based: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn detect_language(&self, path: &str) -> Option<&LanguagePatterns> {
|
||||
let ext = Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())?;
|
||||
|
||||
self.language_patterns
|
||||
.iter()
|
||||
.find(|lang| lang.extensions.contains(&ext))
|
||||
}
|
||||
|
||||
pub fn get_fallback(&self) -> &LanguagePatterns {
|
||||
self.language_patterns.last().unwrap()
|
||||
}
|
||||
|
||||
pub fn parse(&self, source: &str, lang: &LanguagePatterns) -> Vec<HeuristicObject> {
|
||||
let mut objects = Vec::new();
|
||||
let lines: Vec<&str> = source.lines().collect();
|
||||
|
||||
for (pattern_str, kind) in lang.patterns {
|
||||
if let Ok(regex) = Regex::new(pattern_str) {
|
||||
for cap in regex.captures_iter(source) {
|
||||
if let Some(name_match) = cap.get(1) {
|
||||
let byte_start = name_match.start();
|
||||
let line_start = source[..byte_start].matches('\n').count() + 1;
|
||||
|
||||
let line_end = self.find_block_end(
|
||||
&lines,
|
||||
line_start,
|
||||
lang,
|
||||
);
|
||||
|
||||
let indent_level = lines
|
||||
.get(line_start.saturating_sub(1))
|
||||
.map(|l| l.len() - l.trim_start().len())
|
||||
.unwrap_or(0);
|
||||
|
||||
objects.push(HeuristicObject {
|
||||
name: name_match.as_str().to_string(),
|
||||
kind: *kind,
|
||||
line_start,
|
||||
line_end,
|
||||
indent_level,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
objects.sort_by_key(|o| o.line_start);
|
||||
objects.dedup_by(|a, b| a.line_start == b.line_start && a.name == b.name);
|
||||
|
||||
objects
|
||||
}
|
||||
|
||||
fn find_block_end(
|
||||
&self,
|
||||
lines: &[&str],
|
||||
start_line: usize,
|
||||
lang: &LanguagePatterns,
|
||||
) -> usize {
|
||||
let start_idx = start_line.saturating_sub(1);
|
||||
|
||||
if start_idx >= lines.len() {
|
||||
return start_line;
|
||||
}
|
||||
|
||||
if lang.indent_based {
|
||||
return self.find_indent_block_end(lines, start_idx);
|
||||
}
|
||||
|
||||
let (block_start, block_end) = match (lang.block_start, lang.block_end) {
|
||||
(Some(s), Some(e)) => (s, e),
|
||||
(None, Some(e)) => ("", e),
|
||||
_ => return self.find_indent_block_end(lines, start_idx),
|
||||
};
|
||||
|
||||
let mut depth = 0;
|
||||
let mut found_start = block_start.is_empty();
|
||||
|
||||
for (i, line) in lines.iter().enumerate().skip(start_idx) {
|
||||
if !found_start && line.contains(block_start) {
|
||||
found_start = true;
|
||||
depth = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if found_start {
|
||||
for ch in line.chars() {
|
||||
if !block_start.is_empty() && block_start.starts_with(ch) {
|
||||
depth += 1;
|
||||
} else if block_end.starts_with(ch) {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if block_end.len() > 1 && line.trim() == block_end {
|
||||
depth -= 1;
|
||||
if depth <= 0 {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.len()
|
||||
}
|
||||
|
||||
fn find_indent_block_end(&self, lines: &[&str], start_idx: usize) -> usize {
|
||||
if start_idx >= lines.len() {
|
||||
return start_idx + 1;
|
||||
}
|
||||
|
||||
let start_indent = lines[start_idx].len() - lines[start_idx].trim_start().len();
|
||||
|
||||
for (i, line) in lines.iter().enumerate().skip(start_idx + 1) {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let current_indent = line.len() - trimmed.len();
|
||||
if current_indent <= start_indent {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
lines.len()
|
||||
}
|
||||
|
||||
pub fn parse_file(&self, path: &str) -> Result<(String, Vec<HeuristicObject>), String> {
|
||||
let source = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
|
||||
let lang = self.detect_language(path).unwrap_or_else(|| self.get_fallback());
|
||||
let objects = self.parse(&source, lang);
|
||||
|
||||
Ok((lang.name.to_string(), objects))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HeuristicParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_php_parsing() {
|
||||
let source = r#"
|
||||
<?php
|
||||
class MyClass {
|
||||
public function hello() {
|
||||
echo "Hello";
|
||||
}
|
||||
|
||||
private function world() {
|
||||
echo "World";
|
||||
}
|
||||
}
|
||||
|
||||
function standalone() {
|
||||
return 42;
|
||||
}
|
||||
"#;
|
||||
let parser = HeuristicParser::new();
|
||||
let lang = parser.detect_language("test.php").unwrap();
|
||||
let objects = parser.parse(source, lang);
|
||||
|
||||
assert!(objects.iter().any(|o| o.name == "MyClass"));
|
||||
assert!(objects.iter().any(|o| o.name == "hello"));
|
||||
assert!(objects.iter().any(|o| o.name == "world"));
|
||||
assert!(objects.iter().any(|o| o.name == "standalone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kotlin_parsing() {
|
||||
let source = r#"
|
||||
class MyClass {
|
||||
fun hello(): String {
|
||||
return "Hello"
|
||||
}
|
||||
|
||||
suspend fun asyncMethod() {
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
fun topLevel() = 42
|
||||
|
||||
data class User(val name: String)
|
||||
"#;
|
||||
let parser = HeuristicParser::new();
|
||||
let lang = parser.detect_language("test.kt").unwrap();
|
||||
let objects = parser.parse(source, lang);
|
||||
|
||||
assert!(objects.iter().any(|o| o.name == "MyClass"));
|
||||
assert!(objects.iter().any(|o| o.name == "hello"));
|
||||
assert!(objects.iter().any(|o| o.name == "asyncMethod"));
|
||||
assert!(objects.iter().any(|o| o.name == "topLevel"));
|
||||
assert!(objects.iter().any(|o| o.name == "User"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csharp_parsing() {
|
||||
let source = r#"
|
||||
namespace MyApp {
|
||||
public class MyClass {
|
||||
public void Hello() {
|
||||
Console.WriteLine("Hello");
|
||||
}
|
||||
|
||||
private async Task<int> FetchData() {
|
||||
return await Task.FromResult(42);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IService {
|
||||
void DoWork();
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
Active,
|
||||
Inactive
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let parser = HeuristicParser::new();
|
||||
let lang = parser.detect_language("test.cs").unwrap();
|
||||
let objects = parser.parse(source, lang);
|
||||
|
||||
assert!(objects.iter().any(|o| o.name == "MyApp"));
|
||||
assert!(objects.iter().any(|o| o.name == "MyClass"));
|
||||
assert!(objects.iter().any(|o| o.name == "IService"));
|
||||
assert!(objects.iter().any(|o| o.name == "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_swift_parsing() {
|
||||
let source = r#"
|
||||
class MyClass {
|
||||
func hello() -> String {
|
||||
return "Hello"
|
||||
}
|
||||
|
||||
@objc private func callback() {
|
||||
}
|
||||
}
|
||||
|
||||
struct Point {
|
||||
var x: Int
|
||||
var y: Int
|
||||
}
|
||||
|
||||
protocol Drawable {
|
||||
func draw()
|
||||
}
|
||||
"#;
|
||||
let parser = HeuristicParser::new();
|
||||
let lang = parser.detect_language("test.swift").unwrap();
|
||||
let objects = parser.parse(source, lang);
|
||||
|
||||
assert!(objects.iter().any(|o| o.name == "MyClass"));
|
||||
assert!(objects.iter().any(|o| o.name == "hello"));
|
||||
assert!(objects.iter().any(|o| o.name == "Point"));
|
||||
assert!(objects.iter().any(|o| o.name == "Drawable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_for_unknown() {
|
||||
let source = r#"
|
||||
function hello() {
|
||||
print("hello")
|
||||
}
|
||||
|
||||
class Foo {
|
||||
}
|
||||
"#;
|
||||
let parser = HeuristicParser::new();
|
||||
let lang = parser.detect_language("test.xyz");
|
||||
assert!(lang.is_none());
|
||||
|
||||
let fallback = parser.get_fallback();
|
||||
let objects = parser.parse(source, fallback);
|
||||
|
||||
assert!(objects.iter().any(|o| o.name == "hello"));
|
||||
assert!(objects.iter().any(|o| o.name == "Foo"));
|
||||
}
|
||||
}
|
||||
7
src/utils/parsing/mod.rs
Normal file
7
src/utils/parsing/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub mod heuristics;
|
||||
pub mod tree_sitter;
|
||||
pub mod parser;
|
||||
|
||||
pub use heuristics::{HeuristicObject, HeuristicParser};
|
||||
pub use tree_sitter::{Lang, ObjectKind, ParsedObject, TreeSitterParser};
|
||||
pub use parser::{ParseResult, UniversalParser};
|
||||
205
src/utils/parsing/parser.rs
Normal file
205
src/utils/parsing/parser.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
use super::heuristics::{HeuristicParser, HeuristicObject};
|
||||
use super::tree_sitter::{TreeSitterParser, Lang, ParsedObject};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParseResult {
|
||||
pub language: String,
|
||||
pub objects: Vec<ParsedObject>,
|
||||
pub used_heuristics: bool,
|
||||
}
|
||||
|
||||
impl ParsedObject {
|
||||
pub fn from_heuristic(obj: &HeuristicObject) -> Self {
|
||||
Self {
|
||||
name: obj.name.clone(),
|
||||
kind: obj.kind,
|
||||
line_start: obj.line_start,
|
||||
line_end: obj.line_end,
|
||||
byte_start: 0,
|
||||
byte_end: 0,
|
||||
visibility: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Universal parser that tries Tree-sitter first and falls back to heuristics if needed
|
||||
pub struct UniversalParser {
|
||||
tree_sitter_parser: TreeSitterParser,
|
||||
heuristic_parser: HeuristicParser,
|
||||
}
|
||||
|
||||
impl UniversalParser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tree_sitter_parser: TreeSitterParser::new(),
|
||||
heuristic_parser: HeuristicParser::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse source code with a known language using Tree-sitter
|
||||
pub fn parse(&mut self, source: &str, lang: Lang) -> Result<Vec<ParsedObject>, String> {
|
||||
self.tree_sitter_parser.parse(source, lang)
|
||||
}
|
||||
|
||||
/// Parse a file with a known language using Tree-sitter
|
||||
pub fn parse_file(&mut self, path: &str) -> Result<(Lang, Vec<ParsedObject>), String> {
|
||||
self.tree_sitter_parser.parse_file(path)
|
||||
}
|
||||
|
||||
/// Parse a file, trying Tree-sitter first and falling back to heuristics if needed
|
||||
pub fn parse_file_with_fallback(&mut self, path: &str) -> Result<ParseResult, String> {
|
||||
// Try Tree-sitter first if the language is supported
|
||||
if TreeSitterParser::supports(path) {
|
||||
match self.tree_sitter_parser.parse_file(path) {
|
||||
Ok((lang, objects)) => {
|
||||
return Ok(ParseResult {
|
||||
language: lang.name().to_string(),
|
||||
objects,
|
||||
used_heuristics: false,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
// Tree-sitter failed, fall back to heuristics
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to heuristics
|
||||
let (lang_name, heuristic_objects) = self.heuristic_parser.parse_file(path)?;
|
||||
let objects = heuristic_objects
|
||||
.iter()
|
||||
.map(ParsedObject::from_heuristic)
|
||||
.collect();
|
||||
|
||||
Ok(ParseResult {
|
||||
language: lang_name,
|
||||
objects,
|
||||
used_heuristics: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse source code with fallback, given a file path for language detection
|
||||
pub fn parse_source_with_fallback(
|
||||
&mut self,
|
||||
source: &str,
|
||||
path: &str,
|
||||
) -> Result<ParseResult, String> {
|
||||
// Try Tree-sitter first if the language is supported
|
||||
if let Some(lang) = Lang::from_path(path) {
|
||||
match self.tree_sitter_parser.parse(source, lang) {
|
||||
Ok(objects) => {
|
||||
return Ok(ParseResult {
|
||||
language: lang.name().to_string(),
|
||||
objects,
|
||||
used_heuristics: false,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
// Tree-sitter failed, fall back to heuristics
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to heuristics
|
||||
let lang_patterns = self
|
||||
.heuristic_parser
|
||||
.detect_language(path)
|
||||
.unwrap_or_else(|| self.heuristic_parser.get_fallback());
|
||||
|
||||
let heuristic_objects = self.heuristic_parser.parse(source, lang_patterns);
|
||||
let objects = heuristic_objects
|
||||
.iter()
|
||||
.map(ParsedObject::from_heuristic)
|
||||
.collect();
|
||||
|
||||
Ok(ParseResult {
|
||||
language: lang_patterns.name.to_string(),
|
||||
objects,
|
||||
used_heuristics: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UniversalParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_rust_with_tree_sitter() {
|
||||
let source = r#"
|
||||
pub fn hello() {}
|
||||
struct Point { x: i32, y: i32 }
|
||||
impl Point {
|
||||
fn new() -> Self { Self { x: 0, y: 0 } }
|
||||
}
|
||||
"#;
|
||||
let mut parser = UniversalParser::new();
|
||||
let result = parser
|
||||
.parse_source_with_fallback(source, "test.rs")
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.used_heuristics);
|
||||
assert_eq!(result.language, "Rust");
|
||||
assert!(result.objects.iter().any(|o| o.name == "hello"));
|
||||
assert!(result.objects.iter().any(|o| o.name == "Point"));
|
||||
assert!(result.objects.iter().any(|o| o.name == "new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_to_heuristics_php() {
|
||||
let source = r#"
|
||||
<?php
|
||||
class MyClass {
|
||||
public function hello() {
|
||||
echo "Hello";
|
||||
}
|
||||
}
|
||||
|
||||
function standalone() {
|
||||
return 42;
|
||||
}
|
||||
"#;
|
||||
let mut parser = UniversalParser::new();
|
||||
let result = parser
|
||||
.parse_source_with_fallback(source, "test.php")
|
||||
.unwrap();
|
||||
|
||||
assert!(result.used_heuristics);
|
||||
assert_eq!(result.language, "PHP");
|
||||
assert!(result.objects.iter().any(|o| o.name == "MyClass"));
|
||||
assert!(result.objects.iter().any(|o| o.name == "hello"));
|
||||
assert!(result.objects.iter().any(|o| o.name == "standalone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_to_heuristics_kotlin() {
|
||||
let source = r#"
|
||||
class MyClass {
|
||||
fun hello(): String {
|
||||
return "Hello"
|
||||
}
|
||||
}
|
||||
|
||||
fun topLevel() = 42
|
||||
|
||||
data class User(val name: String)
|
||||
"#;
|
||||
let mut parser = UniversalParser::new();
|
||||
let result = parser
|
||||
.parse_source_with_fallback(source, "test.kt")
|
||||
.unwrap();
|
||||
|
||||
assert!(result.used_heuristics);
|
||||
assert_eq!(result.language, "Kotlin");
|
||||
assert!(result.objects.iter().any(|o| o.name == "MyClass"));
|
||||
assert!(result.objects.iter().any(|o| o.name == "hello"));
|
||||
assert!(result.objects.iter().any(|o| o.name == "topLevel"));
|
||||
assert!(result.objects.iter().any(|o| o.name == "User"));
|
||||
}
|
||||
}
|
||||
671
src/utils/parsing/tree_sitter.rs
Normal file
671
src/utils/parsing/tree_sitter.rs
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
use std::path::Path;
|
||||
use tree_sitter::{Language, Parser, Tree};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Lang {
|
||||
Rust,
|
||||
Python,
|
||||
JavaScript,
|
||||
TypeScript,
|
||||
Tsx,
|
||||
Go,
|
||||
Java,
|
||||
C,
|
||||
Cpp,
|
||||
Ruby,
|
||||
Json,
|
||||
Toml,
|
||||
Html,
|
||||
Css,
|
||||
Bash,
|
||||
Markdown,
|
||||
}
|
||||
|
||||
impl Lang {
|
||||
pub fn from_extension(ext: &str) -> Option<Self> {
|
||||
match ext.to_lowercase().as_str() {
|
||||
"rs" => Some(Lang::Rust),
|
||||
"py" | "pyi" | "pyw" => Some(Lang::Python),
|
||||
"js" | "mjs" | "cjs" => Some(Lang::JavaScript),
|
||||
"ts" | "mts" | "cts" => Some(Lang::TypeScript),
|
||||
"tsx" | "jsx" => Some(Lang::Tsx),
|
||||
"go" => Some(Lang::Go),
|
||||
"java" => Some(Lang::Java),
|
||||
"c" | "h" => Some(Lang::C),
|
||||
"cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => Some(Lang::Cpp),
|
||||
"rb" | "rake" | "gemspec" => Some(Lang::Ruby),
|
||||
"json" => Some(Lang::Json),
|
||||
"toml" => Some(Lang::Toml),
|
||||
"html" | "htm" => Some(Lang::Html),
|
||||
"css" | "scss" | "sass" => Some(Lang::Css),
|
||||
"sh" | "bash" | "zsh" => Some(Lang::Bash),
|
||||
"md" | "markdown" => Some(Lang::Markdown),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_path(path: &str) -> Option<Self> {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.and_then(Self::from_extension)
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Lang::Rust => "Rust",
|
||||
Lang::Python => "Python",
|
||||
Lang::JavaScript => "JavaScript",
|
||||
Lang::TypeScript => "TypeScript",
|
||||
Lang::Tsx => "TSX",
|
||||
Lang::Go => "Go",
|
||||
Lang::Java => "Java",
|
||||
Lang::C => "C",
|
||||
Lang::Cpp => "C++",
|
||||
Lang::Ruby => "Ruby",
|
||||
Lang::Json => "JSON",
|
||||
Lang::Toml => "TOML",
|
||||
Lang::Html => "HTML",
|
||||
Lang::Css => "CSS",
|
||||
Lang::Bash => "Bash",
|
||||
Lang::Markdown => "Markdown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tree_sitter_language(&self) -> Language {
|
||||
match self {
|
||||
Lang::Rust => tree_sitter_rust::LANGUAGE.into(),
|
||||
Lang::Python => tree_sitter_python::LANGUAGE.into(),
|
||||
Lang::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
|
||||
Lang::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
|
||||
Lang::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
|
||||
Lang::Go => tree_sitter_go::LANGUAGE.into(),
|
||||
Lang::Java => tree_sitter_java::LANGUAGE.into(),
|
||||
Lang::C => tree_sitter_c::LANGUAGE.into(),
|
||||
Lang::Cpp => tree_sitter_cpp::LANGUAGE.into(),
|
||||
Lang::Ruby => tree_sitter_ruby::LANGUAGE.into(),
|
||||
Lang::Json => tree_sitter_json::LANGUAGE.into(),
|
||||
Lang::Toml => tree_sitter_toml_ng::LANGUAGE.into(),
|
||||
Lang::Html => tree_sitter_html::LANGUAGE.into(),
|
||||
Lang::Css => tree_sitter_css::LANGUAGE.into(),
|
||||
Lang::Bash => tree_sitter_bash::LANGUAGE.into(),
|
||||
Lang::Markdown => tree_sitter_md::LANGUAGE.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn object_node_types(&self) -> &[ObjectNodeMapping] {
|
||||
match self {
|
||||
Lang::Rust => &RUST_MAPPINGS,
|
||||
Lang::Python => &PYTHON_MAPPINGS,
|
||||
Lang::JavaScript | Lang::TypeScript | Lang::Tsx => &JS_TS_MAPPINGS,
|
||||
Lang::Go => &GO_MAPPINGS,
|
||||
Lang::Java => &JAVA_MAPPINGS,
|
||||
Lang::C => &C_MAPPINGS,
|
||||
Lang::Cpp => &CPP_MAPPINGS,
|
||||
Lang::Ruby => &RUBY_MAPPINGS,
|
||||
Lang::Json | Lang::Toml => &CONFIG_MAPPINGS,
|
||||
Lang::Html => &HTML_MAPPINGS,
|
||||
Lang::Css => &CSS_MAPPINGS,
|
||||
Lang::Bash => &BASH_MAPPINGS,
|
||||
Lang::Markdown => &MARKDOWN_MAPPINGS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ObjectKind {
|
||||
Function,
|
||||
Method,
|
||||
Class,
|
||||
Struct,
|
||||
Enum,
|
||||
Interface,
|
||||
Trait,
|
||||
Impl,
|
||||
Module,
|
||||
Constant,
|
||||
Variable,
|
||||
Type,
|
||||
Macro,
|
||||
Import,
|
||||
Export,
|
||||
Property,
|
||||
Field,
|
||||
Section,
|
||||
Rule,
|
||||
}
|
||||
|
||||
impl ObjectKind {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
ObjectKind::Function => "function",
|
||||
ObjectKind::Method => "method",
|
||||
ObjectKind::Class => "class",
|
||||
ObjectKind::Struct => "struct",
|
||||
ObjectKind::Enum => "enum",
|
||||
ObjectKind::Interface => "interface",
|
||||
ObjectKind::Trait => "trait",
|
||||
ObjectKind::Impl => "impl",
|
||||
ObjectKind::Module => "module",
|
||||
ObjectKind::Constant => "constant",
|
||||
ObjectKind::Variable => "variable",
|
||||
ObjectKind::Type => "type",
|
||||
ObjectKind::Macro => "macro",
|
||||
ObjectKind::Import => "import",
|
||||
ObjectKind::Export => "export",
|
||||
ObjectKind::Property => "property",
|
||||
ObjectKind::Field => "field",
|
||||
ObjectKind::Section => "section",
|
||||
ObjectKind::Rule => "rule",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a string representation of an object kind
|
||||
/// Returns None if the string doesn't match any known kind
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"function" | "fn" | "func" | "def" => Some(ObjectKind::Function),
|
||||
"method" => Some(ObjectKind::Method),
|
||||
"class" => Some(ObjectKind::Class),
|
||||
"struct" => Some(ObjectKind::Struct),
|
||||
"enum" => Some(ObjectKind::Enum),
|
||||
"interface" => Some(ObjectKind::Interface),
|
||||
"trait" => Some(ObjectKind::Trait),
|
||||
"impl" => Some(ObjectKind::Impl),
|
||||
"module" | "mod" => Some(ObjectKind::Module),
|
||||
"const" | "constant" => Some(ObjectKind::Constant),
|
||||
"var" | "variable" => Some(ObjectKind::Variable),
|
||||
"type" => Some(ObjectKind::Type),
|
||||
"macro" => Some(ObjectKind::Macro),
|
||||
"import" => Some(ObjectKind::Import),
|
||||
"export" => Some(ObjectKind::Export),
|
||||
"property" => Some(ObjectKind::Property),
|
||||
"field" => Some(ObjectKind::Field),
|
||||
"section" => Some(ObjectKind::Section),
|
||||
"rule" => Some(ObjectKind::Rule),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ObjectNodeMapping {
|
||||
pub node_type: &'static str,
|
||||
pub kind: ObjectKind,
|
||||
pub name_field: Option<&'static str>,
|
||||
pub name_child_type: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ObjectNodeMapping {
|
||||
const fn new(node_type: &'static str, kind: ObjectKind) -> Self {
|
||||
Self {
|
||||
node_type,
|
||||
kind,
|
||||
name_field: Some("name"),
|
||||
name_child_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
const fn with_name_child(mut self, child_type: &'static str) -> Self {
|
||||
self.name_field = None;
|
||||
self.name_child_type = Some(child_type);
|
||||
self
|
||||
}
|
||||
|
||||
const fn no_name(mut self) -> Self {
|
||||
self.name_field = None;
|
||||
self.name_child_type = None;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
static RUST_MAPPINGS: &[ObjectNodeMapping] = &[
|
||||
ObjectNodeMapping::new("function_item", ObjectKind::Function),
|
||||
ObjectNodeMapping::new("struct_item", ObjectKind::Struct),
|
||||
ObjectNodeMapping::new("enum_item", ObjectKind::Enum),
|
||||
ObjectNodeMapping::new("trait_item", ObjectKind::Trait),
|
||||
ObjectNodeMapping::new("impl_item", ObjectKind::Impl).no_name(),
|
||||
ObjectNodeMapping::new("mod_item", ObjectKind::Module),
|
||||
ObjectNodeMapping::new("const_item", ObjectKind::Constant),
|
||||
ObjectNodeMapping::new("static_item", ObjectKind::Variable),
|
||||
ObjectNodeMapping::new("type_item", ObjectKind::Type),
|
||||
ObjectNodeMapping::new("macro_definition", ObjectKind::Macro),
|
||||
ObjectNodeMapping::new("use_declaration", ObjectKind::Import).no_name(),
|
||||
];
|
||||
|
||||
static PYTHON_MAPPINGS: &[ObjectNodeMapping] = &[
|
||||
ObjectNodeMapping::new("function_definition", ObjectKind::Function),
|
||||
ObjectNodeMapping::new("class_definition", ObjectKind::Class),
|
||||
ObjectNodeMapping::new("decorated_definition", ObjectKind::Function).no_name(),
|
||||
ObjectNodeMapping::new("import_statement", ObjectKind::Import).no_name(),
|
||||
ObjectNodeMapping::new("import_from_statement", ObjectKind::Import).no_name(),
|
||||
ObjectNodeMapping::new("assignment", ObjectKind::Variable).no_name(),
|
||||
];
|
||||
|
||||
static JS_TS_MAPPINGS: &[ObjectNodeMapping] = &[
|
||||
ObjectNodeMapping::new("function_declaration", ObjectKind::Function),
|
||||
ObjectNodeMapping::new("function", ObjectKind::Function),
|
||||
ObjectNodeMapping::new("arrow_function", ObjectKind::Function).no_name(),
|
||||
ObjectNodeMapping::new("method_definition", ObjectKind::Method),
|
||||
ObjectNodeMapping::new("class_declaration", ObjectKind::Class),
|
||||
ObjectNodeMapping::new("class", ObjectKind::Class),
|
||||
ObjectNodeMapping::new("interface_declaration", ObjectKind::Interface),
|
||||
ObjectNodeMapping::new("type_alias_declaration", ObjectKind::Type),
|
||||
ObjectNodeMapping::new("enum_declaration", ObjectKind::Enum),
|
||||
ObjectNodeMapping::new("import_statement", ObjectKind::Import).no_name(),
|
||||
ObjectNodeMapping::new("export_statement", ObjectKind::Export).no_name(),
|
||||
ObjectNodeMapping::new("variable_declaration", ObjectKind::Variable).no_name(),
|
||||
ObjectNodeMapping::new("lexical_declaration", ObjectKind::Variable).no_name(),
|
||||
];
|
||||
|
||||
static GO_MAPPINGS: &[ObjectNodeMapping] = &[
|
||||
ObjectNodeMapping::new("function_declaration", ObjectKind::Function),
|
||||
ObjectNodeMapping::new("method_declaration", ObjectKind::Method),
|
||||
ObjectNodeMapping::new("type_declaration", ObjectKind::Type).no_name(),
|
||||
ObjectNodeMapping::new("type_spec", ObjectKind::Type),
|
||||
ObjectNodeMapping::new("struct_type", ObjectKind::Struct).no_name(),
|
||||
ObjectNodeMapping::new("interface_type", ObjectKind::Interface).no_name(),
|
||||
ObjectNodeMapping::new("const_declaration", ObjectKind::Constant).no_name(),
|
||||
ObjectNodeMapping::new("var_declaration", ObjectKind::Variable).no_name(),
|
||||
ObjectNodeMapping::new("import_declaration", ObjectKind::Import).no_name(),
|
||||
];
|
||||
|
||||
static JAVA_MAPPINGS: &[ObjectNodeMapping] = &[
|
||||
ObjectNodeMapping::new("method_declaration", ObjectKind::Method),
|
||||
ObjectNodeMapping::new("constructor_declaration", ObjectKind::Function),
|
||||
ObjectNodeMapping::new("class_declaration", ObjectKind::Class),
|
||||
ObjectNodeMapping::new("interface_declaration", ObjectKind::Interface),
|
||||
ObjectNodeMapping::new("enum_declaration", ObjectKind::Enum),
|
||||
ObjectNodeMapping::new("field_declaration", ObjectKind::Field).no_name(),
|
||||
ObjectNodeMapping::new("import_declaration", ObjectKind::Import).no_name(),
|
||||
ObjectNodeMapping::new("constant_declaration", ObjectKind::Constant).no_name(),
|
||||
];
|
||||
|
||||
static C_MAPPINGS: &[ObjectNodeMapping] = &[
|
||||
ObjectNodeMapping::new("function_definition", ObjectKind::Function)
|
||||
.with_name_child("function_declarator"),
|
||||
ObjectNodeMapping::new("declaration", ObjectKind::Variable).no_name(),
|
||||
ObjectNodeMapping::new("struct_specifier", ObjectKind::Struct).no_name(),
|
||||
ObjectNodeMapping::new("enum_specifier", ObjectKind::Enum).no_name(),
|
||||
ObjectNodeMapping::new("type_definition", ObjectKind::Type).no_name(),
|
||||
ObjectNodeMapping::new("preproc_def", ObjectKind::Macro),
|
||||
ObjectNodeMapping::new("preproc_function_def", ObjectKind::Macro),
|
||||
];
|
||||
|
||||
static CPP_MAPPINGS: &[ObjectNodeMapping] = &[
|
||||
ObjectNodeMapping::new("function_definition", ObjectKind::Function)
|
||||
.with_name_child("function_declarator"),
|
||||
ObjectNodeMapping::new("declaration", ObjectKind::Variable).no_name(),
|
||||
ObjectNodeMapping::new("class_specifier", ObjectKind::Class),
|
||||
ObjectNodeMapping::new("struct_specifier", ObjectKind::Struct).no_name(),
|
||||
ObjectNodeMapping::new("enum_specifier", ObjectKind::Enum).no_name(),
|
||||
ObjectNodeMapping::new("namespace_definition", ObjectKind::Module),
|
||||
ObjectNodeMapping::new("template_declaration", ObjectKind::Type).no_name(),
|
||||
ObjectNodeMapping::new("type_definition", ObjectKind::Type).no_name(),
|
||||
ObjectNodeMapping::new("preproc_def", ObjectKind::Macro),
|
||||
ObjectNodeMapping::new("preproc_function_def", ObjectKind::Macro),
|
||||
];
|
||||
|
||||
static RUBY_MAPPINGS: &[ObjectNodeMapping] = &[
|
||||
ObjectNodeMapping::new("method", ObjectKind::Method),
|
||||
ObjectNodeMapping::new("singleton_method", ObjectKind::Method),
|
||||
ObjectNodeMapping::new("class", ObjectKind::Class),
|
||||
ObjectNodeMapping::new("module", ObjectKind::Module),
|
||||
ObjectNodeMapping::new("constant", ObjectKind::Constant).no_name(),
|
||||
];
|
||||
|
||||
static CONFIG_MAPPINGS: &[ObjectNodeMapping] = &[
|
||||
ObjectNodeMapping::new("pair", ObjectKind::Property).no_name(),
|
||||
ObjectNodeMapping::new("table", ObjectKind::Section).no_name(),
|
||||
ObjectNodeMapping::new("block_mapping_pair", ObjectKind::Property).no_name(),
|
||||
];
|
||||
|
||||
static HTML_MAPPINGS: &[ObjectNodeMapping] = &[
|
||||
ObjectNodeMapping::new("element", ObjectKind::Section).no_name(),
|
||||
ObjectNodeMapping::new("script_element", ObjectKind::Section).no_name(),
|
||||
ObjectNodeMapping::new("style_element", ObjectKind::Section).no_name(),
|
||||
];
|
||||
|
||||
static CSS_MAPPINGS: &[ObjectNodeMapping] = &[
|
||||
ObjectNodeMapping::new("rule_set", ObjectKind::Rule).no_name(),
|
||||
ObjectNodeMapping::new("media_statement", ObjectKind::Rule).no_name(),
|
||||
ObjectNodeMapping::new("keyframes_statement", ObjectKind::Rule).no_name(),
|
||||
];
|
||||
|
||||
static BASH_MAPPINGS: &[ObjectNodeMapping] = &[
|
||||
ObjectNodeMapping::new("function_definition", ObjectKind::Function),
|
||||
ObjectNodeMapping::new("variable_assignment", ObjectKind::Variable).no_name(),
|
||||
];
|
||||
|
||||
static MARKDOWN_MAPPINGS: &[ObjectNodeMapping] = &[
|
||||
ObjectNodeMapping::new("atx_heading", ObjectKind::Section).no_name(),
|
||||
ObjectNodeMapping::new("setext_heading", ObjectKind::Section).no_name(),
|
||||
ObjectNodeMapping::new("fenced_code_block", ObjectKind::Section).no_name(),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedObject {
|
||||
pub name: String,
|
||||
pub kind: ObjectKind,
|
||||
pub line_start: usize,
|
||||
pub line_end: usize,
|
||||
pub byte_start: usize,
|
||||
pub byte_end: usize,
|
||||
pub visibility: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TreeSitterParser {
|
||||
parser: Parser,
|
||||
}
|
||||
|
||||
impl TreeSitterParser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
parser: Parser::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supports(path: &str) -> bool {
|
||||
Lang::from_path(path).is_some()
|
||||
}
|
||||
|
||||
pub fn parse(&mut self, source: &str, lang: Lang) -> Result<Vec<ParsedObject>, String> {
|
||||
self.parser
|
||||
.set_language(&lang.tree_sitter_language())
|
||||
.map_err(|e| format!("Failed to set language: {}", e))?;
|
||||
|
||||
let tree = self
|
||||
.parser
|
||||
.parse(source, None)
|
||||
.ok_or("Failed to parse source code")?;
|
||||
|
||||
Ok(self.extract_objects(&tree, source.as_bytes(), lang))
|
||||
}
|
||||
|
||||
pub fn parse_file(&mut self, path: &str) -> Result<(Lang, Vec<ParsedObject>), String> {
|
||||
let lang = Lang::from_path(path)
|
||||
.ok_or_else(|| format!("Unsupported file extension: {}", path))?;
|
||||
|
||||
let source = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
|
||||
let objects = self.parse(&source, lang)?;
|
||||
Ok((lang, objects))
|
||||
}
|
||||
|
||||
fn extract_objects(&self, tree: &Tree, source: &[u8], lang: Lang) -> Vec<ParsedObject> {
|
||||
let mut objects = Vec::new();
|
||||
let mappings = lang.object_node_types();
|
||||
|
||||
self.visit_node(tree.root_node(), source, mappings, &mut objects, lang);
|
||||
|
||||
objects
|
||||
}
|
||||
|
||||
fn visit_node(
|
||||
&self,
|
||||
node: tree_sitter::Node,
|
||||
source: &[u8],
|
||||
mappings: &[ObjectNodeMapping],
|
||||
objects: &mut Vec<ParsedObject>,
|
||||
lang: Lang,
|
||||
) {
|
||||
let node_type = node.kind();
|
||||
|
||||
if let Some(mapping) = mappings.iter().find(|m| m.node_type == node_type) {
|
||||
if let Some(obj) = self.extract_object(node, source, mapping, lang) {
|
||||
objects.push(obj);
|
||||
}
|
||||
}
|
||||
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
self.visit_node(child, source, mappings, objects, lang);
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_object(
|
||||
&self,
|
||||
node: tree_sitter::Node,
|
||||
source: &[u8],
|
||||
mapping: &ObjectNodeMapping,
|
||||
lang: Lang,
|
||||
) -> Option<ParsedObject> {
|
||||
let name = self.extract_name(node, source, mapping, lang)?;
|
||||
let visibility = self.extract_visibility(node, source, lang);
|
||||
|
||||
Some(ParsedObject {
|
||||
name,
|
||||
kind: mapping.kind,
|
||||
line_start: node.start_position().row + 1,
|
||||
line_end: node.end_position().row + 1,
|
||||
byte_start: node.start_byte(),
|
||||
byte_end: node.end_byte(),
|
||||
visibility,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_name(
|
||||
&self,
|
||||
node: tree_sitter::Node,
|
||||
source: &[u8],
|
||||
mapping: &ObjectNodeMapping,
|
||||
lang: Lang,
|
||||
) -> Option<String> {
|
||||
if let Some(field) = mapping.name_field {
|
||||
if let Some(name_node) = node.child_by_field_name(field) {
|
||||
return name_node.utf8_text(source).ok().map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(child_type) = mapping.name_child_type {
|
||||
return self.find_name_in_children(node, source, child_type);
|
||||
}
|
||||
|
||||
match lang {
|
||||
Lang::Rust if mapping.kind == ObjectKind::Impl => {
|
||||
self.extract_rust_impl_name(node, source)
|
||||
}
|
||||
_ => self.extract_fallback_name(node, source, mapping.kind),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_name_in_children(
|
||||
&self,
|
||||
node: tree_sitter::Node,
|
||||
source: &[u8],
|
||||
target_type: &str,
|
||||
) -> Option<String> {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
if child.kind() == target_type {
|
||||
if let Some(name_node) = child.child_by_field_name("name") {
|
||||
return name_node.utf8_text(source).ok().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(ident) = child.child_by_field_name("declarator") {
|
||||
return self.find_identifier(ident, source);
|
||||
}
|
||||
return child.utf8_text(source).ok().map(|s| s.to_string());
|
||||
}
|
||||
|
||||
if let Some(name) = self.find_name_in_children(child, source, target_type) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_identifier(&self, node: tree_sitter::Node, source: &[u8]) -> Option<String> {
|
||||
if node.kind() == "identifier" {
|
||||
return node.utf8_text(source).ok().map(|s| s.to_string());
|
||||
}
|
||||
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
if let Some(name) = self.find_identifier(child, source) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_rust_impl_name(&self, node: tree_sitter::Node, source: &[u8]) -> Option<String> {
|
||||
let mut cursor = node.walk();
|
||||
let mut trait_name: Option<String> = None;
|
||||
let mut type_name: Option<String> = None;
|
||||
|
||||
for child in node.children(&mut cursor) {
|
||||
match child.kind() {
|
||||
"type_identifier" | "generic_type" | "scoped_type_identifier" => {
|
||||
let text = child.utf8_text(source).ok()?.to_string();
|
||||
if trait_name.is_none() && type_name.is_none() {
|
||||
type_name = Some(text);
|
||||
} else if type_name.is_some() && trait_name.is_none() {
|
||||
trait_name = type_name.take();
|
||||
type_name = Some(text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match (&trait_name, &type_name) {
|
||||
(Some(t), Some(ty)) => Some(format!("{} for {}", t, ty)),
|
||||
(None, Some(ty)) => Some(ty.clone()),
|
||||
_ => Some("<impl>".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_fallback_name(
|
||||
&self,
|
||||
node: tree_sitter::Node,
|
||||
source: &[u8],
|
||||
kind: ObjectKind,
|
||||
) -> Option<String> {
|
||||
let text = node.utf8_text(source).ok()?;
|
||||
let preview: String = text.chars().take(50).collect();
|
||||
let preview = preview.lines().next().unwrap_or(&preview);
|
||||
|
||||
Some(format!("<{}: {}>", kind.name(), preview.trim()))
|
||||
}
|
||||
|
||||
fn extract_visibility(
|
||||
&self,
|
||||
node: tree_sitter::Node,
|
||||
source: &[u8],
|
||||
lang: Lang,
|
||||
) -> Option<String> {
|
||||
match lang {
|
||||
Lang::Rust => {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
if child.kind() == "visibility_modifier" {
|
||||
return child.utf8_text(source).ok().map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Lang::Java | Lang::TypeScript | Lang::Tsx => {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
if child.kind() == "modifiers" || child.kind().contains("modifier") {
|
||||
let text = child.utf8_text(source).ok()?;
|
||||
if text.contains("public")
|
||||
|| text.contains("private")
|
||||
|| text.contains("protected")
|
||||
{
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Lang::Python => {
|
||||
if let Some(name_node) = node.child_by_field_name("name") {
|
||||
let name = name_node.utf8_text(source).ok()?;
|
||||
if name.starts_with("__") && !name.ends_with("__") {
|
||||
return Some("private".to_string());
|
||||
} else if name.starts_with("_") {
|
||||
return Some("protected".to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TreeSitterParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_lang_detection() {
|
||||
assert_eq!(Lang::from_extension("rs"), Some(Lang::Rust));
|
||||
assert_eq!(Lang::from_extension("py"), Some(Lang::Python));
|
||||
assert_eq!(Lang::from_extension("js"), Some(Lang::JavaScript));
|
||||
assert_eq!(Lang::from_extension("ts"), Some(Lang::TypeScript));
|
||||
assert_eq!(Lang::from_extension("go"), Some(Lang::Go));
|
||||
assert_eq!(Lang::from_extension("java"), Some(Lang::Java));
|
||||
assert_eq!(Lang::from_extension("php"), None);
|
||||
assert_eq!(Lang::from_extension("kt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rust() {
|
||||
let source = r#"
|
||||
pub fn hello() {}
|
||||
struct Point { x: i32, y: i32 }
|
||||
impl Point {
|
||||
fn new() -> Self { Self { x: 0, y: 0 } }
|
||||
}
|
||||
"#;
|
||||
let mut parser = TreeSitterParser::new();
|
||||
let objects = parser.parse(source, Lang::Rust).unwrap();
|
||||
|
||||
assert!(objects.iter().any(|o| o.name == "hello"));
|
||||
assert!(objects.iter().any(|o| o.name == "Point"));
|
||||
assert!(objects.iter().any(|o| o.name == "new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_python() {
|
||||
let source = r#"
|
||||
def hello():
|
||||
pass
|
||||
|
||||
class MyClass:
|
||||
def method(self):
|
||||
pass
|
||||
"#;
|
||||
let mut parser = TreeSitterParser::new();
|
||||
let objects = parser.parse(source, Lang::Python).unwrap();
|
||||
|
||||
assert!(objects.iter().any(|o| o.name == "hello"));
|
||||
assert!(objects.iter().any(|o| o.name == "MyClass"));
|
||||
assert!(objects.iter().any(|o| o.name == "method"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_javascript() {
|
||||
let source = r#"
|
||||
function hello() {}
|
||||
class MyClass {
|
||||
method() {}
|
||||
}
|
||||
const x = 1;
|
||||
"#;
|
||||
let mut parser = TreeSitterParser::new();
|
||||
let objects = parser.parse(source, Lang::JavaScript).unwrap();
|
||||
|
||||
assert!(objects.iter().any(|o| o.name == "hello"));
|
||||
assert!(objects.iter().any(|o| o.name == "MyClass"));
|
||||
}
|
||||
}
|
||||
28
src/utils/shortcuts.rs
Normal file
28
src/utils/shortcuts.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use rustyline::error::ReadlineError;
|
||||
|
||||
/// Handle readline errors, including shortcut-triggered exits
|
||||
/// Ctrl+C triggers Interrupted, Ctrl+D and Ctrl+Q trigger EOF
|
||||
pub fn handle_readline_error(err: ReadlineError) -> Result<Action, String> {
|
||||
match err {
|
||||
ReadlineError::Interrupted => {
|
||||
// Ctrl+C - cancel current operation
|
||||
Ok(Action::Cancel)
|
||||
}
|
||||
ReadlineError::Eof => {
|
||||
// Ctrl+D or Ctrl+Q - quit the application
|
||||
Ok(Action::Quit)
|
||||
}
|
||||
err => Err(format!("Readline error: {:?}", err)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Actions that can be triggered by keyboard shortcuts
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Continue normal operation
|
||||
Continue,
|
||||
/// Cancel current operation (Ctrl+C)
|
||||
Cancel,
|
||||
/// Quit the application (Ctrl+Q, Ctrl+D)
|
||||
Quit,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue