diff --git a/Cargo.toml b/Cargo.toml index 8761c26..9792d19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [ "crates/cli", "crates/llm/ollama", "crates/config" -] +, "crates/tools/fs"] resolver = "2" [workspace.package] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 1b5d046..4c5cf9e 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "code" +name = "owlen" version = "0.1.0" edition.workspace = true license.workspace = true @@ -12,6 +12,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" color-eyre = "0.6" llm-ollama = { path = "../llm/ollama" } +tools-fs = {path = "../tools/fs"} config-agent = { package = "config-agent", path = "../config" } futures-util = "0.3.31" diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 36cecfe..fed9d17 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -8,7 +8,7 @@ use std::io::{self, Write}; #[derive(clap::Subcommand, Debug)] enum Cmd { Read {path: String}, - Glob {root: String}, + Glob {pattern: String}, Grep {root: String, pattern: String}, } @@ -42,8 +42,8 @@ async fn main() -> Result<()> { println!("{}", s); return Ok(()); } - Cmd::Glob { root } => { - for p in tools_fs::glob_list(&root)? { + Cmd::Glob { pattern } => { + for p in tools_fs::glob_list(&pattern)? { println!("{}", p); } return Ok(()); diff --git a/crates/cli/tests/chat_stream.rs b/crates/cli/tests/chat_stream.rs index 8a001f5..67443a0 100644 --- a/crates/cli/tests/chat_stream.rs +++ b/crates/cli/tests/chat_stream.rs @@ -27,7 +27,7 @@ async fn headless_streams_ndjson() { .body(response); }); - let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("code")); + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen")); cmd.arg("--ollama-url").arg(server.base_url()) .arg("--model").arg("qwen2.5") .arg("--print") diff --git a/crates/llm/ollama/src/client.rs b/crates/llm/ollama/src/client.rs index 4a0c386..d009ece 100644 --- a/crates/llm/ollama/src/client.rs +++ b/crates/llm/ollama/src/client.rs @@ -8,6 +8,7 @@ use thiserror::Error; pub struct OllamaClient { http: Client, base_url: String, // e.g. "http://localhost:11434" + api_key: Option, // For Ollama Cloud authentication } #[derive(Debug, Clone, Default)] @@ -31,9 +32,15 @@ impl OllamaClient { Self { http: Client::new(), base_url: base_url.into().trim_end_matches('/').to_string(), + api_key: None, } } + pub fn with_api_key(mut self, api_key: impl Into) -> Self { + self.api_key = Some(api_key.into()); + self + } + pub fn with_cloud() -> Self { // Same API, different base Self::new("https://ollama.com") @@ -52,7 +59,14 @@ impl OllamaClient { } let url = format!("{}/api/chat", self.base_url); let body = Body {model: &opts.model, messages, stream: true}; - let resp = self.http.post(url).json(&body).send().await?; + let mut req = self.http.post(url).json(&body); + + // Add Authorization header if API key is present + if let Some(ref key) = self.api_key { + req = req.header("Authorization", format!("Bearer {}", key)); + } + + let resp = req.send().await?; let bytes_stream = resp.bytes_stream(); // NDJSON parser: split by '\n', parse each as JSON and stream the results diff --git a/crates/tools/fs/Cargo.toml b/crates/tools/fs/Cargo.toml new file mode 100644 index 0000000..69d5b2e --- /dev/null +++ b/crates/tools/fs/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tools-fs" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +ignore = "0.4" +walkdir = "2.5" +globset = "0.4" +grep-regex = "0.1" +grep-searcher = "0.1" +color-eyre = "0.6" +tempfile = "3.23.0" \ No newline at end of file diff --git a/crates/tools/fs/src/lib.rs b/crates/tools/fs/src/lib.rs new file mode 100644 index 0000000..37246f4 --- /dev/null +++ b/crates/tools/fs/src/lib.rs @@ -0,0 +1,77 @@ +use color_eyre::eyre::Result; +use ignore::WalkBuilder; +use grep_regex::RegexMatcher; +use grep_searcher::{sinks::UTF8, SearcherBuilder}; +use globset::Glob; + +pub fn read_file(path: &str) -> Result { + Ok(std::fs::read_to_string(path)?) +} + +pub fn glob_list(pattern: &str) -> Result> { + let glob = Glob::new(pattern)?.compile_matcher(); + + // Extract the literal prefix to determine the root directory + let root = pattern + .split("**") + .next() + .and_then(|s| { + let trimmed = s.trim_end_matches('/'); + if trimmed.is_empty() { None } else { Some(trimmed) } + }) + .unwrap_or("."); + + let mut out = Vec::new(); + for result in WalkBuilder::new(root) + .standard_filters(true) + .git_ignore(true) + .git_global(false) + .git_exclude(false) + .require_git(false) + .build() + { + let entity = result?; + if entity.file_type().map(|filetype| filetype.is_file()).unwrap_or(false) { + if let Some(path) = entity.path().to_str() { + // Match against the glob pattern + if glob.is_match(path) { + out.push(path.to_string()); + } + } + } + } + Ok(out) +} + +pub fn grep(root: &str, pattern: &str) -> Result> { + let matcher = RegexMatcher::new_line_matcher(pattern)?; + let mut searcher = SearcherBuilder::new().line_number(true).build(); + + let mut results = Vec::new(); + for result in WalkBuilder::new(root) + .standard_filters(true) + .git_ignore(true) + .git_global(false) + .git_exclude(false) + .require_git(false) + .build() + { + let entity = result?; + if !entity.file_type().map(|filetype| filetype.is_file()).unwrap_or(false) { continue; } + let path = entity.path().to_path_buf(); + let mut line_hits: Vec<(usize, String)> = Vec::new(); + let sink = UTF8(|line_number, line| { + line_hits.push((line_number as usize, line.to_string())); + Ok(true) + }); + let _ = searcher.search_path(&matcher, &path, sink); + if !line_hits.is_empty() { + let p = path.to_string_lossy().to_string(); + for (line_number, text) in line_hits { + results.push((p.clone(), line_number, text)); + } + } + } + + Ok(results) +} \ No newline at end of file diff --git a/crates/tools/fs/tests/fs_tools.rs b/crates/tools/fs/tests/fs_tools.rs new file mode 100644 index 0000000..6111bbd --- /dev/null +++ b/crates/tools/fs/tests/fs_tools.rs @@ -0,0 +1,28 @@ +use tools_fs::{read_file, glob_list, grep}; +use std::fs; +use tempfile::tempdir; + +#[test] +fn read_and_glob_respect_gitignore() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("a.txt"), "hello").unwrap(); + fs::create_dir(root.join("secret")).unwrap(); + fs::write(root.join("secret/secret.txt"), "token=123").unwrap(); + fs::write(root.join(".gitignore"), "secret/\n").unwrap(); + + let files = glob_list(root.to_str().unwrap()).unwrap(); + assert!(files.iter().any(|p| p.ends_with("a.txt"))); + assert!(!files.iter().any(|p| p.contains("secret.txt"))); + assert_eq!(read_file(root.join("a.txt").to_str().unwrap()).unwrap(), "hello"); +} + +#[test] +fn grep_finds_lines() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("a.rs"), "fn main() { println!(\"hello\"); }").unwrap(); + + let hits = grep(root.to_str().unwrap(), "hello").unwrap(); + assert!(hits.iter().any(|(_p, _ln, text)| text.contains("hello"))); +} \ No newline at end of file