slug-code/src/slugmd/mod.rs
Bryan Ramos b8bf9029fe Initial Slug Code Rust implementation
Core features:
- OpenAI-compatible streaming provider (vLLM, Ollama, OpenAI, etc.)
- Agent loop with tool use (bash, read, write, edit, glob, grep)
- Permission system: ask/yolo/sandbox/allowEdits + glob patterns
- SLUG.md hierarchy loaded every turn (CLAUDE.md equivalent)
- Session persistence with --continue/--resume/--fork-session
- Hook system: 5 lifecycle events, command + prompt types
- Compaction: ToolResultTrim/Truncate strategies, /compact command
- Config via TOML, CLI args, env vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:23:04 -04:00

141 lines
4.6 KiB
Rust

use std::fs;
use std::path::PathBuf;
const MAX_CHARS: usize = 40_000;
/// Read a file and return its trimmed contents, or None if the file doesn't exist.
fn read_file_optional(path: &PathBuf) -> Option<String> {
match fs::read_to_string(path) {
Ok(contents) => {
let trimmed = contents.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
Err(_) => None,
}
}
/// Load all SLUG.md config files in priority order and return a concatenated string.
///
/// Priority (later overrides earlier):
/// 1. ~/.slug/SLUG.md — global user preferences
/// 2. ./SLUG.md — project-level (in working directory)
/// 3. .slug/rules/*.md — modular rule files in the project
/// 4. SLUG.local.md — private notes (gitignored)
///
/// Returns an empty string if no config files are found.
/// Truncates with a warning if the combined content exceeds 40,000 characters.
pub fn load_slug_context() -> String {
let mut sections: Vec<(String, String)> = Vec::new();
// 1. Global user preferences: ~/.slug/SLUG.md
if let Some(home_dir) = dirs::home_dir() {
let global_path = home_dir.join(".slug").join("SLUG.md");
if let Some(content) = read_file_optional(&global_path) {
sections.push(("# Global Rules (~/.slug/SLUG.md)".to_string(), content));
}
}
// 2. Project-level: ./SLUG.md
let project_path = PathBuf::from("SLUG.md");
if let Some(content) = read_file_optional(&project_path) {
sections.push(("# Project Rules (SLUG.md)".to_string(), content));
}
// 3. Modular rule files: .slug/rules/*.md
let rules_dir = PathBuf::from(".slug/rules");
if rules_dir.is_dir() {
let mut rule_files: Vec<PathBuf> = match fs::read_dir(&rules_dir) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|ext| ext == "md").unwrap_or(false))
.collect(),
Err(_) => Vec::new(),
};
// Sort for deterministic ordering
rule_files.sort();
for rule_file in rule_files {
if let Some(content) = read_file_optional(&rule_file) {
let filename = rule_file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let header = format!("# Project Rule (.slug/rules/{filename})");
sections.push((header, content));
}
}
}
// 4. Private local notes: ./SLUG.local.md
let local_path = PathBuf::from("SLUG.local.md");
if let Some(content) = read_file_optional(&local_path) {
sections.push((
"# Local Notes (SLUG.local.md)".to_string(),
content,
));
}
if sections.is_empty() {
return String::new();
}
// Concatenate sections with headers
let mut result = String::new();
for (header, content) in &sections {
if !result.is_empty() {
result.push('\n');
}
result.push_str(header);
result.push('\n');
result.push_str(content);
result.push('\n');
}
// Enforce character budget
if result.len() > MAX_CHARS {
result.truncate(MAX_CHARS);
// Try to truncate at a clean line boundary
if let Some(last_newline) = result.rfind('\n') {
result.truncate(last_newline + 1);
}
result.push_str("\n[SLUG context truncated: exceeded 40,000 character limit]\n");
eprintln!(
"Warning: SLUG.md context exceeded {MAX_CHARS} characters and was truncated."
);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_slug_context_no_files() {
// In a directory without any SLUG files, should return empty string
// (This test is environment-dependent — it passes when run from a clean dir)
let result = load_slug_context();
// Just verify it doesn't panic and returns a String
let _ = result.len();
}
#[test]
fn test_truncation_logic() {
let long_content = "x".repeat(MAX_CHARS + 100);
let mut result = long_content;
if result.len() > MAX_CHARS {
result.truncate(MAX_CHARS);
if let Some(last_newline) = result.rfind('\n') {
result.truncate(last_newline + 1);
}
result.push_str("\n[SLUG context truncated: exceeded 40,000 character limit]\n");
}
assert!(result.contains("truncated"));
}
}