Skip to main content

hematite/agent/
instructions.rs

1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone)]
6pub struct InstructionFile {
7    pub _path: PathBuf,
8    pub content: String,
9}
10
11/// Discovers instruction files from the current directory up to the root.
12pub fn discover_instruction_files(cwd: &Path) -> Vec<InstructionFile> {
13    let mut directories = Vec::new();
14    let mut cursor = Some(cwd);
15    while let Some(dir) = cursor {
16        directories.push(dir.to_path_buf());
17        cursor = dir.parent();
18    }
19    directories.reverse();
20
21    let mut files = Vec::new();
22    let mut seen_hashes = HashSet::new();
23
24    for dir in directories {
25        for candidate_name in [
26            "HEMATITE.md",
27            "HEMATITE.local.md",
28            ".hematite/rules.md",
29            ".hematite/instructions.md",
30        ] {
31            let candidate_path = if candidate_name.contains('/') {
32                let parts: Vec<&str> = candidate_name.split('/').collect();
33                dir.join(parts[0]).join(parts[1])
34            } else {
35                dir.join(candidate_name)
36            };
37
38            if let Ok(content) = fs::read_to_string(&candidate_path) {
39                let trimmed = content.trim();
40                if !trimmed.is_empty() {
41                    // Simple hash/dedupe based on content to ignore shadowed files.
42                    let hash = stable_hash(trimmed);
43                    if seen_hashes.contains(&hash) {
44                        continue;
45                    }
46                    seen_hashes.insert(hash);
47                    files.push(InstructionFile {
48                        _path: candidate_path,
49                        content: trimmed.to_string(),
50                    });
51                }
52            }
53        }
54    }
55    files
56}
57
58fn stable_hash(s: &str) -> u64 {
59    use std::collections::hash_map::DefaultHasher;
60    use std::hash::{Hash, Hasher};
61    let mut hasher = DefaultHasher::new();
62    s.hash(&mut hasher);
63    hasher.finish()
64}
65
66/// Renders instruction files into a prompt section with a limit on characters.
67pub fn render_instructions(files: &[InstructionFile], max_chars: usize) -> Option<String> {
68    if files.is_empty() {
69        return None;
70    }
71
72    let mut output = Vec::new();
73    output.push("# Project Instructions".to_string());
74    output.push(
75        "These rules were discovered in the directory tree for the current repository:".to_string(),
76    );
77
78    let mut remaining = max_chars;
79    for file in files {
80        if remaining < 100 {
81            output.push("\n... [further instructions omitted due to context limit]".to_string());
82            break;
83        }
84
85        let content = if file.content.len() > remaining {
86            format!("{}\n... [truncated]", &file.content[..remaining - 20])
87        } else {
88            file.content.clone()
89        };
90
91        remaining = remaining.saturating_sub(content.len());
92        output.push(format!("\n## Source: HEMATITE FILE\n{}", content));
93    }
94
95    Some(output.join("\n"))
96}