This commit is contained in:
taggart_comet 2026-01-11 12:12:58 +02:00
parent edf99cb06a
commit d570ca5444
54 changed files with 6704 additions and 69 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
/target
/.idea
/.venv
venv/
/.claude
/models/
./models/

1486
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

46
Cargo.toml Normal file
View 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"

Binary file not shown.

24
cats/cat.py Normal file
View 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
View 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
View 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
View 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,
}
}
}

View 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
}

View file

@ -0,0 +1,7 @@
mod general;
mod session;
mod tools;
pub use general::*;
pub use session::*;
pub use tools::*;

View file

@ -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
)
}

View file

@ -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
}

View 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};

View 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()
}
}

View 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(),
}
}
}

View 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),
}
}
}

View file

@ -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()
}
}

View file

@ -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);
}
}

View 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
"
}
}

View file

@ -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))
}
}

View file

@ -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
"
}
}

View file

@ -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
"
}
}

View 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
"
}
}

View file

@ -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 {
""
}
}

View file

@ -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
View 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;
}

View file

@ -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));
}
}

View 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
"
}
}

View 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);
}
}

View file

@ -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()
}
}

View file

@ -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,
}
}
}

View file

@ -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"));
}
}

View file

@ -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
View 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)
}

View 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)
}
}

View 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);
}
}

View 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::*;

View 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
View 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
View 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
View 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
View 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
View 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),
}
}
}

View 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
View 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),
}
}
}

View 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
View 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
View 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};

View 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
View 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
View 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"));
}
}

View 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
View 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,
}