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")); }
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(¤t_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 ¤t_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(¤t_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 ¤t_dir,
251 &[ASTER_HINTS_FILENAME.to_string()],
252 &gitignore,
253 );
254
255 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(¤t_dir).unwrap();
279
280 let gitignore = create_dummy_gitignore();
281 let hints = load_hint_files(
282 ¤t_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(¤t_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 ¤t_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}