@@ -10,6 +10,163 @@ use crate::hints::import_files::read_referenced_files;
1010pub const GOOSE_HINTS_FILENAME : & str = ".goosehints" ;
1111pub const AGENTS_MD_FILENAME : & str = "AGENTS.md" ;
1212
13+ pub fn get_context_filenames ( ) -> Vec < String > {
14+ use crate :: config:: Config ;
15+
16+ Config :: global ( )
17+ . get_param :: < Vec < String > > ( "CONTEXT_FILE_NAMES" )
18+ . unwrap_or_else ( |_| {
19+ vec ! [
20+ GOOSE_HINTS_FILENAME . to_string( ) ,
21+ AGENTS_MD_FILENAME . to_string( ) ,
22+ ]
23+ } )
24+ }
25+
26+ /// Tracks which subdirectories have been accessed during a session and loads
27+ /// hint files from those directories on demand.
28+ #[ derive( Default ) ]
29+ pub struct SubdirectoryHintTracker {
30+ loaded_dirs : HashSet < PathBuf > ,
31+ pending_dirs : Vec < PathBuf > ,
32+ hints_filenames : Vec < String > ,
33+ }
34+
35+ impl SubdirectoryHintTracker {
36+ pub fn new ( ) -> Self {
37+ Self {
38+ loaded_dirs : HashSet :: new ( ) ,
39+ pending_dirs : Vec :: new ( ) ,
40+ hints_filenames : get_context_filenames ( ) ,
41+ }
42+ }
43+
44+ /// Record directories referenced by a tool call's arguments.
45+ /// Extracts parent directories from `path` arguments and path-like tokens
46+ /// in `command` arguments.
47+ pub fn record_tool_arguments (
48+ & mut self ,
49+ arguments : & Option < serde_json:: Map < String , serde_json:: Value > > ,
50+ working_dir : & Path ,
51+ ) {
52+ let args = match arguments. as_ref ( ) {
53+ Some ( a) => a,
54+ None => return ,
55+ } ;
56+
57+ if let Some ( path_str) = args. get ( "path" ) . and_then ( |v| v. as_str ( ) ) {
58+ if let Some ( dir) = resolve_to_parent_dir ( path_str, working_dir) {
59+ self . pending_dirs . push ( dir) ;
60+ }
61+ }
62+
63+ if let Some ( cmd) = args. get ( "command" ) . and_then ( |v| v. as_str ( ) ) {
64+ for token in shell_words:: split ( cmd) . unwrap_or_default ( ) {
65+ if token. starts_with ( '-' ) {
66+ continue ;
67+ }
68+ if token. contains ( std:: path:: MAIN_SEPARATOR ) || token. contains ( '.' ) {
69+ if let Some ( dir) = resolve_to_parent_dir ( & token, working_dir) {
70+ self . pending_dirs . push ( dir) ;
71+ }
72+ }
73+ }
74+ }
75+ }
76+
77+ /// Process pending directories and return any newly discovered hints as
78+ /// `(key, content)` pairs suitable for adding to the system prompt.
79+ pub fn load_new_hints ( & mut self , working_dir : & Path ) -> Vec < ( String , String ) > {
80+ let pending = std:: mem:: take ( & mut self . pending_dirs ) ;
81+ if pending. is_empty ( ) {
82+ return Vec :: new ( ) ;
83+ }
84+
85+ let mut results = Vec :: new ( ) ;
86+ for dir in pending {
87+ if !dir. starts_with ( working_dir) || dir == working_dir {
88+ continue ;
89+ }
90+ if self . loaded_dirs . contains ( & dir) {
91+ continue ;
92+ }
93+ if let Some ( content) =
94+ load_hints_from_directory ( & dir, working_dir, & self . hints_filenames )
95+ {
96+ let key = format ! ( "subdir_hints:{}" , dir. display( ) ) ;
97+ results. push ( ( key, content) ) ;
98+ }
99+ self . loaded_dirs . insert ( dir) ;
100+ }
101+ results
102+ }
103+ }
104+
105+ fn resolve_to_parent_dir ( token : & str , working_dir : & Path ) -> Option < PathBuf > {
106+ let path = Path :: new ( token) ;
107+ let resolved = if path. is_absolute ( ) {
108+ path. to_path_buf ( )
109+ } else {
110+ working_dir. join ( path)
111+ } ;
112+ resolved. parent ( ) . map ( |d| d. to_path_buf ( ) )
113+ }
114+
115+ fn load_hints_from_directory (
116+ directory : & Path ,
117+ working_dir : & Path ,
118+ hints_filenames : & [ String ] ,
119+ ) -> Option < String > {
120+ if !directory. is_dir ( ) || !directory. is_absolute ( ) {
121+ return None ;
122+ }
123+
124+ if !directory. starts_with ( working_dir) || directory == working_dir {
125+ return None ;
126+ }
127+
128+ let git_root = find_git_root ( working_dir) ;
129+ let import_boundary = git_root. unwrap_or ( working_dir) ;
130+ let gitignore = Gitignore :: empty ( ) ;
131+
132+ let mut directories: Vec < PathBuf > = directory
133+ . ancestors ( )
134+ . take_while ( |d| d. starts_with ( working_dir) && * d != working_dir)
135+ . map ( |d| d. to_path_buf ( ) )
136+ . collect ( ) ;
137+ directories. reverse ( ) ;
138+
139+ let mut contents = Vec :: new ( ) ;
140+ for dir in & directories {
141+ for hints_filename in hints_filenames {
142+ let hints_path = dir. join ( hints_filename) ;
143+ if hints_path. is_file ( ) {
144+ let mut visited = HashSet :: new ( ) ;
145+ let expanded = read_referenced_files (
146+ & hints_path,
147+ import_boundary,
148+ & mut visited,
149+ 0 ,
150+ & gitignore,
151+ ) ;
152+ if !expanded. is_empty ( ) {
153+ contents. push ( expanded) ;
154+ }
155+ }
156+ }
157+ }
158+
159+ if contents. is_empty ( ) {
160+ None
161+ } else {
162+ Some ( format ! (
163+ "### Subdirectory Hints ({})\n {}" ,
164+ directory. display( ) ,
165+ contents. join( "\n " )
166+ ) )
167+ }
168+ }
169+
13170fn find_git_root ( start_dir : & Path ) -> Option < & Path > {
14171 let mut check_dir = start_dir;
15172
@@ -466,4 +623,111 @@ End of hints"#;
466623 assert ! ( hints. contains( "Root file content" ) ) ;
467624 assert ! ( hints. contains( "--- Content from ../root_file.md ---" ) ) ;
468625 }
626+
627+ #[ test]
628+ fn resolve_to_parent_dir_relative ( ) {
629+ let wd = Path :: new ( "/home/user/project" ) ;
630+ assert_eq ! (
631+ resolve_to_parent_dir( "src/main.rs" , wd) ,
632+ Some ( PathBuf :: from( "/home/user/project/src" ) )
633+ ) ;
634+ }
635+
636+ #[ test]
637+ fn resolve_to_parent_dir_absolute ( ) {
638+ let wd = Path :: new ( "/home/user/project" ) ;
639+ assert_eq ! (
640+ resolve_to_parent_dir( "/tmp/foo.rs" , wd) ,
641+ Some ( PathBuf :: from( "/tmp" ) )
642+ ) ;
643+ }
644+
645+ #[ test]
646+ fn tracker_records_path_argument ( ) {
647+ let wd = PathBuf :: from ( "/home/user/project" ) ;
648+ let mut tracker = SubdirectoryHintTracker :: new ( ) ;
649+ let args: serde_json:: Map < String , serde_json:: Value > =
650+ serde_json:: from_str ( r#"{"path": "src/main.rs"}"# ) . unwrap ( ) ;
651+ tracker. record_tool_arguments ( & Some ( args) , & wd) ;
652+ // pending_dirs is private, so verify via load_new_hints returning empty
653+ // (directories don't exist on disk, so no hints loaded, but dirs get marked)
654+ let hints = tracker. load_new_hints ( & wd) ;
655+ assert ! ( hints. is_empty( ) ) ;
656+ // The dir should now be in loaded_dirs
657+ assert ! ( tracker
658+ . loaded_dirs
659+ . contains( & PathBuf :: from( "/home/user/project/src" ) ) ) ;
660+ }
661+
662+ #[ test]
663+ fn tracker_records_command_argument ( ) {
664+ let wd = PathBuf :: from ( "/home/user/project" ) ;
665+ let mut tracker = SubdirectoryHintTracker :: new ( ) ;
666+ let args: serde_json:: Map < String , serde_json:: Value > =
667+ serde_json:: from_str ( r#"{"command": "cat nested/doc.md"}"# ) . unwrap ( ) ;
668+ tracker. record_tool_arguments ( & Some ( args) , & wd) ;
669+ let hints = tracker. load_new_hints ( & wd) ;
670+ assert ! ( hints. is_empty( ) ) ;
671+ assert ! ( tracker
672+ . loaded_dirs
673+ . contains( & PathBuf :: from( "/home/user/project/nested" ) ) ) ;
674+ }
675+
676+ #[ test]
677+ fn tracker_skips_flags_in_command ( ) {
678+ let wd = PathBuf :: from ( "/home/user/project" ) ;
679+ let mut tracker = SubdirectoryHintTracker :: new ( ) ;
680+ let args: serde_json:: Map < String , serde_json:: Value > =
681+ serde_json:: from_str ( r#"{"command": "grep -rn pattern src/lib.rs"}"# ) . unwrap ( ) ;
682+ tracker. record_tool_arguments ( & Some ( args) , & wd) ;
683+ let _ = tracker. load_new_hints ( & wd) ;
684+ assert ! ( tracker
685+ . loaded_dirs
686+ . contains( & PathBuf :: from( "/home/user/project/src" ) ) ) ;
687+ // flags like -rn should not produce entries
688+ assert_eq ! ( tracker. loaded_dirs. len( ) , 1 ) ;
689+ }
690+
691+ #[ test]
692+ fn tracker_loads_subdirectory_hints ( ) {
693+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
694+ let project_root = temp_dir. path ( ) . to_path_buf ( ) ;
695+ let subdir = project_root. join ( "nested" ) ;
696+ fs:: create_dir_all ( & subdir) . unwrap ( ) ;
697+ fs:: write (
698+ subdir. join ( GOOSE_HINTS_FILENAME ) ,
699+ "nested subdirectory hints" ,
700+ )
701+ . unwrap ( ) ;
702+
703+ let mut tracker = SubdirectoryHintTracker :: new ( ) ;
704+ let args: serde_json:: Map < String , serde_json:: Value > =
705+ serde_json:: from_str ( r#"{"path": "nested/foo.rs"}"# ) . unwrap ( ) ;
706+ tracker. record_tool_arguments ( & Some ( args) , & project_root) ;
707+ let hints = tracker. load_new_hints ( & project_root) ;
708+ assert_eq ! ( hints. len( ) , 1 ) ;
709+ assert ! ( hints[ 0 ] . 0 . contains( "nested" ) ) ;
710+ assert ! ( hints[ 0 ] . 1 . contains( "nested subdirectory hints" ) ) ;
711+ }
712+
713+ #[ test]
714+ fn tracker_deduplicates_directories ( ) {
715+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
716+ let project_root = temp_dir. path ( ) . to_path_buf ( ) ;
717+ let subdir = project_root. join ( "nested" ) ;
718+ fs:: create_dir_all ( & subdir) . unwrap ( ) ;
719+ fs:: write ( subdir. join ( GOOSE_HINTS_FILENAME ) , "nested hints" ) . unwrap ( ) ;
720+
721+ let mut tracker = SubdirectoryHintTracker :: new ( ) ;
722+ let args: serde_json:: Map < String , serde_json:: Value > =
723+ serde_json:: from_str ( r#"{"path": "nested/foo.rs"}"# ) . unwrap ( ) ;
724+ tracker. record_tool_arguments ( & Some ( args. clone ( ) ) , & project_root) ;
725+ let hints = tracker. load_new_hints ( & project_root) ;
726+ assert_eq ! ( hints. len( ) , 1 ) ;
727+
728+ // Second time same dir — should return nothing
729+ tracker. record_tool_arguments ( & Some ( args) , & project_root) ;
730+ let hints = tracker. load_new_hints ( & project_root) ;
731+ assert ! ( hints. is_empty( ) ) ;
732+ }
469733}
0 commit comments