Skip to main content

aster/hints/
load_hints.rs

1use ignore::gitignore::Gitignore;
2use std::{
3    collections::HashSet,
4    path::{Path, PathBuf},
5};
6
7use crate::config::paths::Paths;
8use crate::hints::import_files::read_referenced_files;
9
10pub const ASTER_HINTS_FILENAME: &str = ".asterhints";
11pub const AGENTS_MD_FILENAME: &str = "AGENTS.md";
12
13fn find_git_root(start_dir: &Path) -> Option<&Path> {
14    let mut check_dir = start_dir;
15
16    loop {
17        if check_dir.join(".git").exists() {
18            return Some(check_dir);
19        }
20        if let Some(parent) = check_dir.parent() {
21            check_dir = parent;
22        } else {
23            break;
24        }
25    }
26
27    None
28}
29
30fn get_local_directories(git_root: Option<&Path>, cwd: &Path) -> Vec<PathBuf> {
31    match git_root {
32        Some(git_root) => {
33            let mut directories = Vec::new();
34            let mut current_dir = cwd;
35
36            loop {
37                directories.push(current_dir.to_path_buf());
38                if current_dir == git_root {
39                    break;
40                }
41                if let Some(parent) = current_dir.parent() {
42                    current_dir = parent;
43                } else {
44                    break;
45                }
46            }
47            directories.reverse();
48            directories
49        }
50        None => vec![cwd.to_path_buf()],
51    }
52}
53
54pub fn load_hint_files(
55    cwd: &Path,
56    hints_filenames: &[String],
57    ignore_patterns: &Gitignore,
58) -> String {
59    let mut global_hints_contents = Vec::with_capacity(hints_filenames.len());
60    let mut local_hints_contents = Vec::with_capacity(hints_filenames.len());
61
62    for hints_filename in hints_filenames {
63        let global_hints_path = Paths::in_config_dir(hints_filename);
64        if global_hints_path.is_file() {
65            let mut visited = HashSet::new();
66            let hints_dir = global_hints_path.parent().unwrap();
67            let expanded_content = read_referenced_files(
68                &global_hints_path,
69                hints_dir,
70                &mut visited,
71                0,
72                ignore_patterns,
73            );
74            if !expanded_content.is_empty() {
75                global_hints_contents.push(expanded_content);
76            }
77        }
78    }
79    let git_root = find_git_root(cwd);
80    let local_directories = get_local_directories(git_root, cwd);
81
82    let import_boundary = git_root.unwrap_or(cwd);
83
84    for directory in &local_directories {
85        for hints_filename in hints_filenames {
86            let hints_path = directory.join(hints_filename);
87            if hints_path.is_file() {
88                let mut visited = HashSet::new();
89                let expanded_content = read_referenced_files(
90                    &hints_path,
91                    import_boundary,
92                    &mut visited,
93                    0,
94                    ignore_patterns,
95                );
96                if !expanded_content.is_empty() {
97                    local_hints_contents.push(expanded_content);
98                }
99            }
100        }
101    }
102
103    let mut hints = String::new();
104    if !global_hints_contents.is_empty() {
105        hints.push_str("\n### Global Hints\nThese are my global aster hints.\n");
106        hints.push_str(&global_hints_contents.join("\n"));
107    }
108
109    if !local_hints_contents.is_empty() {
110        if !hints.is_empty() {
111            hints.push_str("\n\n");
112        }
113        hints.push_str(
114            "### Project Hints\nThese are hints for working on the project in this directory.\n",
115        );
116        hints.push_str(&local_hints_contents.join("\n"));
117    }
118
119    hints
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use ignore::gitignore::GitignoreBuilder;
126    use std::fs::{self};
127    use tempfile::TempDir;
128
129    fn create_dummy_gitignore() -> Gitignore {
130        let temp_dir = tempfile::tempdir().expect("failed to create tempdir");
131        let builder = GitignoreBuilder::new(temp_dir.path());
132        builder.build().expect("failed to build gitignore")
133    }
134
135    #[test]
136    fn test_asterhints_when_present() {
137        let dir = TempDir::new().unwrap();
138
139        fs::write(dir.path().join(ASTER_HINTS_FILENAME), "Test hint content").unwrap();
140        let gitignore = create_dummy_gitignore();
141        let hints = load_hint_files(dir.path(), &[ASTER_HINTS_FILENAME.to_string()], &gitignore);
142
143        assert!(hints.contains("Test hint content"));
144    }
145
146    #[test]
147    fn test_asterhints_when_missing() {
148        let dir = TempDir::new().unwrap();
149
150        let gitignore = create_dummy_gitignore();
151        let hints = load_hint_files(dir.path(), &[ASTER_HINTS_FILENAME.to_string()], &gitignore);
152
153        assert!(!hints.contains("Project Hints"));
154    }
155
156    #[test]
157    fn test_asterhints_multiple_filenames() {
158        let dir = TempDir::new().unwrap();
159
160        fs::write(
161            dir.path().join("CLAUDE.md"),
162            "Custom hints file content from CLAUDE.md",
163        )
164        .unwrap();
165        fs::write(
166            dir.path().join(ASTER_HINTS_FILENAME),
167            "Custom hints file content from .asterhints",
168        )
169        .unwrap();
170
171        let gitignore = create_dummy_gitignore();
172        let hints = load_hint_files(
173            dir.path(),
174            &["CLAUDE.md".to_string(), ASTER_HINTS_FILENAME.to_string()],
175            &gitignore,
176        );
177
178        assert!(hints.contains("Custom hints file content from CLAUDE.md"));
179        assert!(hints.contains("Custom hints file content from .asterhints"));
180    }
181
182    #[test]
183    fn test_asterhints_configurable_filename() {
184        let dir = TempDir::new().unwrap();
185
186        fs::write(dir.path().join("CLAUDE.md"), "Custom hints file content").unwrap();
187        let gitignore = create_dummy_gitignore();
188        let hints = load_hint_files(dir.path(), &["CLAUDE.md".to_string()], &gitignore);
189
190        assert!(hints.contains("Custom hints file content"));
191        assert!(!hints.contains(".asterhints")); // Make sure it's not loading the default
192    }
193
194    #[test]
195    fn test_nested_asterhints_with_git_root() {
196        let temp_dir = TempDir::new().unwrap();
197        let project_root = temp_dir.path();
198
199        fs::create_dir(project_root.join(".git")).unwrap();
200        fs::write(
201            project_root.join(ASTER_HINTS_FILENAME),
202            "Root hints content",
203        )
204        .unwrap();
205
206        let subdir = project_root.join("subdir");
207        fs::create_dir(&subdir).unwrap();
208        fs::write(subdir.join(ASTER_HINTS_FILENAME), "Subdir hints content").unwrap();
209        let current_dir = subdir.join("current_dir");
210        fs::create_dir(&current_dir).unwrap();
211        fs::write(
212            current_dir.join(ASTER_HINTS_FILENAME),
213            "current_dir hints content",
214        )
215        .unwrap();
216
217        let gitignore = create_dummy_gitignore();
218        let hints = load_hint_files(
219            &current_dir,
220            &[ASTER_HINTS_FILENAME.to_string()],
221            &gitignore,
222        );
223
224        assert!(
225            hints.contains("Root hints content\nSubdir hints content\ncurrent_dir hints content")
226        );
227    }
228
229    #[test]
230    fn test_nested_asterhints_without_git_root() {
231        let temp_dir = TempDir::new().unwrap();
232        let base_dir = temp_dir.path();
233
234        fs::write(base_dir.join(ASTER_HINTS_FILENAME), "Base hints content").unwrap();
235
236        let subdir = base_dir.join("subdir");
237        fs::create_dir(&subdir).unwrap();
238        fs::write(subdir.join(ASTER_HINTS_FILENAME), "Subdir hints content").unwrap();
239
240        let current_dir = subdir.join("current_dir");
241        fs::create_dir(&current_dir).unwrap();
242        fs::write(
243            current_dir.join(ASTER_HINTS_FILENAME),
244            "Current dir hints content",
245        )
246        .unwrap();
247
248        let gitignore = create_dummy_gitignore();
249        let hints = load_hint_files(
250            &current_dir,
251            &[ASTER_HINTS_FILENAME.to_string()],
252            &gitignore,
253        );
254
255        // Without .git, should only find hints in current directory
256        assert!(hints.contains("Current dir hints content"));
257        assert!(!hints.contains("Base hints content"));
258        assert!(!hints.contains("Subdir hints content"));
259    }
260
261    #[test]
262    fn test_nested_asterhints_mixed_filenames() {
263        let temp_dir = TempDir::new().unwrap();
264        let project_root = temp_dir.path();
265
266        fs::create_dir(project_root.join(".git")).unwrap();
267        fs::write(project_root.join("CLAUDE.md"), "Root CLAUDE.md content").unwrap();
268
269        let subdir = project_root.join("subdir");
270        fs::create_dir(&subdir).unwrap();
271        fs::write(
272            subdir.join(ASTER_HINTS_FILENAME),
273            "Subdir .asterhints content",
274        )
275        .unwrap();
276
277        let current_dir = subdir.join("current_dir");
278        fs::create_dir(&current_dir).unwrap();
279
280        let gitignore = create_dummy_gitignore();
281        let hints = load_hint_files(
282            &current_dir,
283            &["CLAUDE.md".to_string(), ASTER_HINTS_FILENAME.to_string()],
284            &gitignore,
285        );
286
287        assert!(hints.contains("Root CLAUDE.md content"));
288        assert!(hints.contains("Subdir .asterhints content"));
289    }
290
291    #[test]
292    fn test_hints_with_basic_imports() {
293        let temp_dir = TempDir::new().unwrap();
294        let project_root = temp_dir.path();
295
296        fs::create_dir(project_root.join(".git")).unwrap();
297
298        fs::write(project_root.join("README.md"), "# Project README").unwrap();
299        fs::write(project_root.join("config.md"), "Configuration details").unwrap();
300
301        let hints_content = r#"Project hints content
302@README.md
303@config.md
304Additional instructions here."#;
305        fs::write(project_root.join(ASTER_HINTS_FILENAME), hints_content).unwrap();
306
307        let gitignore = create_dummy_gitignore();
308        let hints = load_hint_files(
309            project_root,
310            &[ASTER_HINTS_FILENAME.to_string()],
311            &gitignore,
312        );
313
314        assert!(hints.contains("Project hints content"));
315        assert!(hints.contains("Additional instructions here"));
316
317        assert!(hints.contains("--- Content from README.md ---"));
318        assert!(hints.contains("# Project README"));
319        assert!(hints.contains("--- End of README.md ---"));
320
321        assert!(hints.contains("--- Content from config.md ---"));
322        assert!(hints.contains("Configuration details"));
323        assert!(hints.contains("--- End of config.md ---"));
324    }
325
326    #[test]
327    fn test_hints_with_git_import_boundary() {
328        let temp_dir = TempDir::new().unwrap();
329        let project_root = temp_dir.path();
330
331        fs::create_dir(project_root.join(".git")).unwrap();
332
333        fs::write(project_root.join("root_file.md"), "Root file content").unwrap();
334        fs::write(
335            project_root.join("shared_docs.md"),
336            "Shared documentation content",
337        )
338        .unwrap();
339
340        let docs_dir = project_root.join("docs");
341        fs::create_dir_all(&docs_dir).unwrap();
342        fs::write(docs_dir.join("api.md"), "API documentation content").unwrap();
343
344        let utils_dir = project_root.join("src").join("utils");
345        fs::create_dir_all(&utils_dir).unwrap();
346        fs::write(
347            utils_dir.join("helpers.md"),
348            "Helper utilities content @../../shared_docs.md",
349        )
350        .unwrap();
351
352        let components_dir = project_root.join("src").join("components");
353        fs::create_dir_all(&components_dir).unwrap();
354        fs::write(components_dir.join("local_file.md"), "Local file content").unwrap();
355
356        let outside_dir = temp_dir.path().parent().unwrap();
357        fs::write(outside_dir.join("forbidden.md"), "Forbidden content").unwrap();
358
359        let root_hints_content = r#"Project root hints
360@docs/api.md
361Root level instructions"#;
362        fs::write(project_root.join(ASTER_HINTS_FILENAME), root_hints_content).unwrap();
363
364        let nested_hints_content = r#"Nested directory hints
365@local_file.md
366@../utils/helpers.md
367@../../docs/api.md
368@../../root_file.md
369@../../../forbidden.md
370End of nested hints"#;
371        fs::write(
372            components_dir.join(ASTER_HINTS_FILENAME),
373            nested_hints_content,
374        )
375        .unwrap();
376
377        let gitignore = create_dummy_gitignore();
378        let hints = load_hint_files(
379            &components_dir,
380            &[ASTER_HINTS_FILENAME.to_string()],
381            &gitignore,
382        );
383        println!("======{}", hints);
384        assert!(hints.contains("Project root hints"));
385        assert!(hints.contains("Root level instructions"));
386
387        assert!(hints.contains("API documentation content"));
388        assert!(hints.contains("--- Content from docs/api.md ---"));
389
390        assert!(hints.contains("Nested directory hints"));
391        assert!(hints.contains("End of nested hints"));
392
393        assert!(hints.contains("Local file content"));
394        assert!(hints.contains("--- Content from local_file.md ---"));
395
396        assert!(hints.contains("Helper utilities content"));
397        assert!(hints.contains("--- Content from ../utils/helpers.md ---"));
398        assert!(hints.contains("Shared documentation content"));
399        assert!(hints.contains("--- Content from ../../shared_docs.md ---"));
400
401        let api_content_count = hints.matches("API documentation content").count();
402        assert_eq!(
403            api_content_count, 2,
404            "API content should appear twice - from root and nested hints"
405        );
406
407        assert!(hints.contains("Root file content"));
408        assert!(hints.contains("--- Content from ../../root_file.md ---"));
409
410        assert!(!hints.contains("Forbidden content"));
411        assert!(hints.contains("@../../../forbidden.md"));
412    }
413
414    #[test]
415    fn test_hints_without_git_import_boundary() {
416        let temp_dir = TempDir::new().unwrap();
417        let base_dir = temp_dir.path();
418
419        let current_dir = base_dir.join("current");
420        fs::create_dir(&current_dir).unwrap();
421        fs::write(current_dir.join("local.md"), "Local content").unwrap();
422
423        fs::write(base_dir.join("parent.md"), "Parent content").unwrap();
424
425        let hints_content = r#"Current directory hints
426@local.md
427@../parent.md
428End of hints"#;
429        fs::write(current_dir.join(ASTER_HINTS_FILENAME), hints_content).unwrap();
430
431        let gitignore = create_dummy_gitignore();
432        let hints = load_hint_files(
433            &current_dir,
434            &[ASTER_HINTS_FILENAME.to_string()],
435            &gitignore,
436        );
437
438        assert!(hints.contains("Local content"));
439        assert!(hints.contains("--- Content from local.md ---"));
440
441        assert!(!hints.contains("Parent content"));
442        assert!(hints.contains("@../parent.md"));
443    }
444
445    #[test]
446    fn test_import_boundary_respects_nested_setting() {
447        let temp_dir = TempDir::new().unwrap();
448        let project_root = temp_dir.path();
449        fs::create_dir(project_root.join(".git")).unwrap();
450        fs::write(project_root.join("root_file.md"), "Root file content").unwrap();
451        let subdir = project_root.join("subdir");
452        fs::create_dir(&subdir).unwrap();
453        fs::write(subdir.join("local_file.md"), "Local file content").unwrap();
454        let hints_content = r#"Subdir hints
455@local_file.md
456@../root_file.md
457End of hints"#;
458        fs::write(subdir.join(ASTER_HINTS_FILENAME), hints_content).unwrap();
459        let gitignore = create_dummy_gitignore();
460
461        let hints = load_hint_files(&subdir, &[ASTER_HINTS_FILENAME.to_string()], &gitignore);
462
463        assert!(hints.contains("Local file content"));
464        assert!(hints.contains("--- Content from local_file.md ---"));
465
466        assert!(hints.contains("Root file content"));
467        assert!(hints.contains("--- Content from ../root_file.md ---"));
468    }
469}