fix: move goosehints/AGENTS.md handling to goose, and out of developer extension (#5575)

This commit is contained in:
Alex Hancock
2025-11-06 17:04:46 -05:00
committed by GitHub
parent e0f0898781
commit c62c61cc07
12 changed files with 76 additions and 261 deletions
+488
View File
@@ -0,0 +1,488 @@
use ignore::gitignore::Gitignore;
use once_cell::sync::Lazy;
use std::{
collections::HashSet,
path::{Path, PathBuf},
};
static FILE_REFERENCE_REGEX: Lazy<regex::Regex> = Lazy::new(|| {
regex::Regex::new(r"(?:^|\s)@([a-zA-Z0-9_\-./]+(?:\.[a-zA-Z0-9]+)+|[A-Z][a-zA-Z0-9_\-]*|[a-zA-Z0-9_\-./]*[./][a-zA-Z0-9_\-./]*)")
.expect("Invalid file reference regex pattern")
});
const MAX_DEPTH: usize = 3;
fn sanitize_reference_path(
reference: &Path,
including_file_path: &Path,
import_boundary: &Path,
) -> Result<PathBuf, std::io::Error> {
if reference.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Absolute paths not allowed in file references",
));
}
let resolved = including_file_path.join(reference);
let boundary_canonical = import_boundary.canonicalize().map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"Import boundary directory not found",
)
})?;
if let Ok(canonical) = resolved.canonicalize() {
if !canonical.starts_with(&boundary_canonical) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"Include: '{}' is outside the import boundary '{}'",
resolved.display(),
import_boundary.display()
),
));
}
Ok(canonical)
} else {
Ok(resolved) // File doesn't exist, but path structure is safe
}
}
fn parse_file_references(content: &str) -> Vec<PathBuf> {
// Keep size limits for ReDoS protection - .goosehints should be reasonably sized
const MAX_CONTENT_LENGTH: usize = 131_072; // 128KB limit
if content.len() > MAX_CONTENT_LENGTH {
tracing::warn!(
"Content too large for file reference parsing: {} bytes (limit: {} bytes)",
content.len(),
MAX_CONTENT_LENGTH
);
return Vec::new();
}
FILE_REFERENCE_REGEX
.captures_iter(content)
.map(|cap| PathBuf::from(&cap[1]))
.collect()
}
fn should_process_reference(
reference: &Path,
including_file_path: &Path,
import_boundary: &Path,
visited: &HashSet<PathBuf>,
ignore_patterns: &Gitignore,
) -> Option<PathBuf> {
if visited.contains(reference) {
return None;
}
let safe_path = match sanitize_reference_path(reference, including_file_path, import_boundary) {
Ok(path) => path,
Err(_) => {
tracing::warn!("Skipping unsafe file reference: {:?}", reference);
return None;
}
};
if ignore_patterns.matched(&safe_path, false).is_ignore() {
tracing::debug!("Skipping ignored file reference: {:?}", safe_path);
return None;
}
if !safe_path.is_file() {
return None;
}
Some(safe_path)
}
fn process_file_reference(
reference: &Path,
safe_path: &Path,
visited: &mut HashSet<PathBuf>,
import_boundary: &Path,
depth: usize,
ignore_patterns: &Gitignore,
) -> Option<(String, String)> {
if depth >= MAX_DEPTH {
tracing::warn!("Maximum reference depth {} exceeded", MAX_DEPTH);
return None;
}
visited.insert(reference.to_path_buf());
let expanded_content = read_referenced_files(
safe_path,
import_boundary,
visited,
depth + 1,
ignore_patterns,
);
let reference_pattern = format!("@{}", reference.to_string_lossy());
let replacement = format!(
"--- Content from {} ---\n{}\n--- End of {} ---",
reference.display(),
expanded_content,
reference.display()
);
visited.remove(reference);
Some((reference_pattern, replacement))
}
pub fn read_referenced_files(
file_path: &Path,
import_boundary: &Path,
visited: &mut HashSet<PathBuf>,
depth: usize,
ignore_patterns: &Gitignore,
) -> String {
let content = match std::fs::read_to_string(file_path) {
Ok(content) => content,
Err(e) => {
tracing::warn!("Could not read file {:?}: {}", file_path, e);
return String::new();
}
};
let including_file_path = file_path.parent().unwrap_or(file_path);
let references = parse_file_references(&content);
let mut result = content.to_string();
for reference in references {
let safe_path = match should_process_reference(
&reference,
including_file_path,
import_boundary,
visited,
ignore_patterns,
) {
Some(path) => path,
None => continue,
};
if let Some((pattern, replacement)) = process_file_reference(
&reference,
&safe_path,
visited,
import_boundary,
depth,
ignore_patterns,
) {
result = result.replace(&pattern, &replacement);
}
}
result
}
#[cfg(test)]
mod tests {
use ignore::gitignore::GitignoreBuilder;
use super::*;
#[test]
fn test_parse_file_references() {
let content = r#"
Basic file references: @README.md @./docs/guide.md @../shared/config.json @/absolute/path/file.txt
Inline references: @file1.txt and @file2.py
Files with extensions: @component.tsx @file.test.js @config.local.json
Files without extensions: @Makefile @LICENSE @Dockerfile @CHANGELOG
Complex paths: @src/utils/helper.js @docs/api/endpoints.md
Should not match:
- Email addresses: user@example.com admin@company.org
- Social handles: @username @user123
- URLs: https://example.com/@user
"#;
let references = parse_file_references(content);
// Should match expected file references
let expected_files = [
"README.md",
"./docs/guide.md",
"../shared/config.json",
"/absolute/path/file.txt",
"file1.txt",
"file2.py",
"component.tsx",
"file.test.js",
"config.local.json",
"Makefile",
"LICENSE",
"Dockerfile",
"CHANGELOG",
"src/utils/helper.js",
"docs/api/endpoints.md",
];
for expected in expected_files {
assert!(
references.contains(&PathBuf::from(expected)),
"Expected to find reference: {}",
expected
);
}
// Should not match email addresses or social handles
assert!(!references
.iter()
.any(|p| p.to_str().unwrap().contains("example.com")));
assert!(!references
.iter()
.any(|p| p.to_str().unwrap().contains("company.org")));
assert!(!references.iter().any(|p| p.to_str().unwrap() == "username"));
assert!(!references.iter().any(|p| p.to_str().unwrap() == "user123"));
}
mod read_referenced_files {
use super::*;
fn create_ignore_patterns(import_boundary: &Path) -> Gitignore {
let builder = GitignoreBuilder::new(import_boundary);
builder.build().unwrap()
}
fn create_file(import_boundary: &Path, file_name: &str, content: &str) -> PathBuf {
let file_path = import_boundary.join(file_name);
std::fs::write(&file_path, content).unwrap();
file_path
}
#[test]
fn test_direct_reference() {
let temp_dir = tempfile::tempdir().unwrap();
let import_boundary = temp_dir.path();
create_file(
import_boundary,
"basic_included_file.md",
"This is basic content",
);
let ignore_patterns = create_ignore_patterns(import_boundary);
let mut visited = HashSet::new();
let main_file = create_file(
import_boundary,
"main.md",
"Main content\n@basic_included_file.md\nMore content",
);
let expanded = read_referenced_files(
&main_file,
import_boundary,
&mut visited,
0,
&ignore_patterns,
);
assert!(expanded.contains("Main content"));
assert!(expanded.contains("--- Content from"));
assert!(expanded.contains("This is basic content"));
assert!(expanded.contains("--- End of"));
assert!(expanded.contains("More content"));
}
#[test]
fn test_nested_reference() {
let temp_dir = tempfile::tempdir().unwrap();
let import_boundary = temp_dir.path();
create_file(import_boundary, "level1.md", "Level 1 content\n@level2.md");
create_file(import_boundary, "level2.md", "Level 2 content");
let mut visited = HashSet::new();
let main_file = create_file(import_boundary, "main.md", "Main content\n@level1.md");
let ignore_patterns = create_ignore_patterns(import_boundary);
let expanded = read_referenced_files(
&main_file,
import_boundary,
&mut visited,
0,
&ignore_patterns,
);
assert!(expanded.contains("Main content"));
assert!(expanded.contains("Level 1 content"));
assert!(expanded.contains("Level 2 content"));
}
#[test]
fn test_circular_reference() {
let temp_dir = tempfile::tempdir().unwrap();
let import_boundary = temp_dir.path();
let ignore_patterns = create_ignore_patterns(import_boundary);
create_file(import_boundary, "file1.md", "File 1\n@file2.md");
create_file(import_boundary, "file2.md", "File 2\n@file1.md");
let main_file = create_file(import_boundary, "main.md", "Main\n@file1.md");
let mut visited = HashSet::new();
let expanded = read_referenced_files(
&main_file,
import_boundary,
&mut visited,
0,
&ignore_patterns,
);
assert!(expanded.contains("File 1"));
assert!(expanded.contains("File 2"));
// Should only appear once due to circular reference protection
let file1_count = expanded.matches("File 1").count();
assert_eq!(file1_count, 1);
}
#[test]
fn test_max_depth_limit() {
let temp_dir = tempfile::tempdir().unwrap();
let import_boundary = temp_dir.path();
let ignore_patterns = create_ignore_patterns(import_boundary);
let mut visited = HashSet::new();
for i in 1..=5 {
let content = if i < 5 {
format!("Level {} content\n@level{}.md", i, i + 1)
} else {
format!("Level {} content", i)
};
create_file(import_boundary, &format!("level{}.md", i), &content);
}
let main_file = create_file(import_boundary, "main.md", "Main\n@level1.md");
let expanded = read_referenced_files(
&main_file,
import_boundary,
&mut visited,
0,
&ignore_patterns,
);
// Should contain up to level 3 (MAX_DEPTH = 3)
assert!(expanded.contains("Level 1 content"));
assert!(expanded.contains("Level 2 content"));
assert!(expanded.contains("Level 3 content"));
// Should not contain level 4 or 5 due to depth limit
assert!(!expanded.contains("Level 4 content"));
assert!(!expanded.contains("Level 5 content"));
}
#[test]
fn test_missing_file() {
let temp_dir = tempfile::tempdir().unwrap();
let import_boundary = temp_dir.path();
let ignore_patterns = create_ignore_patterns(import_boundary);
let mut visited = HashSet::new();
let main_file = create_file(
import_boundary,
"main.md",
"Main\n@missing.md\nMore content",
);
let expanded = read_referenced_files(
&main_file,
import_boundary,
&mut visited,
0,
&ignore_patterns,
);
assert!(expanded.contains("@missing.md"));
assert!(!expanded.contains("--- Content from"));
}
#[test]
fn test_read_referenced_files_respects_ignore() {
let temp_dir = tempfile::tempdir().unwrap();
let import_boundary = temp_dir.path();
create_file(import_boundary, "allowed.md", "Allowed content");
create_file(import_boundary, "secret.md", "Secret content");
let mut builder = GitignoreBuilder::new(import_boundary);
builder.add_line(None, "secret.md").unwrap();
let ignore_patterns = builder.build().unwrap();
let mut visited = HashSet::new();
// Create main content with references
let content = "Main\n@allowed.md\n@secret.md";
let main_file = create_file(import_boundary, "main.md", content);
let expanded = read_referenced_files(
&main_file,
import_boundary,
&mut visited,
0,
&ignore_patterns,
);
// Should contain allowed content but not ignored content
assert!(expanded.contains("Allowed content"));
assert!(!expanded.contains("Secret content"));
// The @secret.md reference should remain unchanged
assert!(expanded.contains("@secret.md"));
temp_dir.close().unwrap();
}
#[test]
fn test_security_integration_with_file_expansion() {
let temp_dir = tempfile::tempdir().unwrap();
let import_boundary = temp_dir.path();
let ignore_patterns = create_ignore_patterns(import_boundary);
// Create a legitimate file
create_file(
import_boundary,
"legitimate_file.md",
"This is safe content",
);
let absolute_path_file = create_file(
import_boundary,
"used_with_absolute_path.md",
"Absolute path content",
);
let absolute_path_file_path = absolute_path_file
.canonicalize()
.unwrap()
.to_string_lossy()
.into_owned();
// Create a config file attempting path traversal
let malicious_content = format!(
r#"
Normal content here.
@../etc/passwd
@{}
@legitimate_file.md
"#,
absolute_path_file_path
);
create_file(import_boundary, "main.md", &malicious_content);
let mut visited = HashSet::new();
let expanded = read_referenced_files(
&import_boundary.join("main.md"),
import_boundary,
&mut visited,
0,
&ignore_patterns,
);
// Should contain the legitimate file but not the malicious attempts
assert!(expanded.contains("This is safe content"));
assert!(!expanded.contains("root:")); // Common content in /etc/passwd
assert!(!expanded.contains("Absolute path content"));
// The malicious references should still be present (not expanded)
assert!(expanded.contains("@../etc/passwd"));
assert!(expanded.contains(absolute_path_file_path.as_str()));
}
}
}
+469
View File
@@ -0,0 +1,469 @@
use ignore::gitignore::Gitignore;
use std::{
collections::HashSet,
path::{Path, PathBuf},
};
use crate::config::paths::Paths;
use crate::hints::import_files::read_referenced_files;
pub const GOOSE_HINTS_FILENAME: &str = ".goosehints";
pub const AGENTS_MD_FILENAME: &str = "AGENTS.md";
fn find_git_root(start_dir: &Path) -> Option<&Path> {
let mut check_dir = start_dir;
loop {
if check_dir.join(".git").exists() {
return Some(check_dir);
}
if let Some(parent) = check_dir.parent() {
check_dir = parent;
} else {
break;
}
}
None
}
fn get_local_directories(git_root: Option<&Path>, cwd: &Path) -> Vec<PathBuf> {
match git_root {
Some(git_root) => {
let mut directories = Vec::new();
let mut current_dir = cwd;
loop {
directories.push(current_dir.to_path_buf());
if current_dir == git_root {
break;
}
if let Some(parent) = current_dir.parent() {
current_dir = parent;
} else {
break;
}
}
directories.reverse();
directories
}
None => vec![cwd.to_path_buf()],
}
}
pub fn load_hint_files(
cwd: &Path,
hints_filenames: &[String],
ignore_patterns: &Gitignore,
) -> String {
let mut global_hints_contents = Vec::with_capacity(hints_filenames.len());
let mut local_hints_contents = Vec::with_capacity(hints_filenames.len());
for hints_filename in hints_filenames {
let global_hints_path = Paths::in_config_dir(hints_filename);
if global_hints_path.is_file() {
let mut visited = HashSet::new();
let hints_dir = global_hints_path.parent().unwrap();
let expanded_content = read_referenced_files(
&global_hints_path,
hints_dir,
&mut visited,
0,
ignore_patterns,
);
if !expanded_content.is_empty() {
global_hints_contents.push(expanded_content);
}
}
}
let git_root = find_git_root(cwd);
let local_directories = get_local_directories(git_root, cwd);
let import_boundary = git_root.unwrap_or(cwd);
for directory in &local_directories {
for hints_filename in hints_filenames {
let hints_path = directory.join(hints_filename);
if hints_path.is_file() {
let mut visited = HashSet::new();
let expanded_content = read_referenced_files(
&hints_path,
import_boundary,
&mut visited,
0,
ignore_patterns,
);
if !expanded_content.is_empty() {
local_hints_contents.push(expanded_content);
}
}
}
}
let mut hints = String::new();
if !global_hints_contents.is_empty() {
hints.push_str("\n### Global Hints\nThese are my global goose hints.\n");
hints.push_str(&global_hints_contents.join("\n"));
}
if !local_hints_contents.is_empty() {
if !hints.is_empty() {
hints.push_str("\n\n");
}
hints.push_str(
"### Project Hints\nThese are hints for working on the project in this directory.\n",
);
hints.push_str(&local_hints_contents.join("\n"));
}
hints
}
#[cfg(test)]
mod tests {
use super::*;
use ignore::gitignore::GitignoreBuilder;
use std::fs::{self};
use tempfile::TempDir;
fn create_dummy_gitignore() -> Gitignore {
let temp_dir = tempfile::tempdir().expect("failed to create tempdir");
let builder = GitignoreBuilder::new(temp_dir.path());
builder.build().expect("failed to build gitignore")
}
#[test]
fn test_goosehints_when_present() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(GOOSE_HINTS_FILENAME), "Test hint content").unwrap();
let gitignore = create_dummy_gitignore();
let hints = load_hint_files(dir.path(), &[GOOSE_HINTS_FILENAME.to_string()], &gitignore);
assert!(hints.contains("Test hint content"));
}
#[test]
fn test_goosehints_when_missing() {
let dir = TempDir::new().unwrap();
let gitignore = create_dummy_gitignore();
let hints = load_hint_files(dir.path(), &[GOOSE_HINTS_FILENAME.to_string()], &gitignore);
assert!(!hints.contains("Project Hints"));
}
#[test]
fn test_goosehints_multiple_filenames() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("CLAUDE.md"),
"Custom hints file content from CLAUDE.md",
)
.unwrap();
fs::write(
dir.path().join(GOOSE_HINTS_FILENAME),
"Custom hints file content from .goosehints",
)
.unwrap();
let gitignore = create_dummy_gitignore();
let hints = load_hint_files(
dir.path(),
&["CLAUDE.md".to_string(), GOOSE_HINTS_FILENAME.to_string()],
&gitignore,
);
assert!(hints.contains("Custom hints file content from CLAUDE.md"));
assert!(hints.contains("Custom hints file content from .goosehints"));
}
#[test]
fn test_goosehints_configurable_filename() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("CLAUDE.md"), "Custom hints file content").unwrap();
let gitignore = create_dummy_gitignore();
let hints = load_hint_files(dir.path(), &["CLAUDE.md".to_string()], &gitignore);
assert!(hints.contains("Custom hints file content"));
assert!(!hints.contains(".goosehints")); // Make sure it's not loading the default
}
#[test]
fn test_nested_goosehints_with_git_root() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
fs::create_dir(project_root.join(".git")).unwrap();
fs::write(
project_root.join(GOOSE_HINTS_FILENAME),
"Root hints content",
)
.unwrap();
let subdir = project_root.join("subdir");
fs::create_dir(&subdir).unwrap();
fs::write(subdir.join(GOOSE_HINTS_FILENAME), "Subdir hints content").unwrap();
let current_dir = subdir.join("current_dir");
fs::create_dir(&current_dir).unwrap();
fs::write(
current_dir.join(GOOSE_HINTS_FILENAME),
"current_dir hints content",
)
.unwrap();
let gitignore = create_dummy_gitignore();
let hints = load_hint_files(
&current_dir,
&[GOOSE_HINTS_FILENAME.to_string()],
&gitignore,
);
assert!(
hints.contains("Root hints content\nSubdir hints content\ncurrent_dir hints content")
);
}
#[test]
fn test_nested_goosehints_without_git_root() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
fs::write(base_dir.join(GOOSE_HINTS_FILENAME), "Base hints content").unwrap();
let subdir = base_dir.join("subdir");
fs::create_dir(&subdir).unwrap();
fs::write(subdir.join(GOOSE_HINTS_FILENAME), "Subdir hints content").unwrap();
let current_dir = subdir.join("current_dir");
fs::create_dir(&current_dir).unwrap();
fs::write(
current_dir.join(GOOSE_HINTS_FILENAME),
"Current dir hints content",
)
.unwrap();
let gitignore = create_dummy_gitignore();
let hints = load_hint_files(
&current_dir,
&[GOOSE_HINTS_FILENAME.to_string()],
&gitignore,
);
// Without .git, should only find hints in current directory
assert!(hints.contains("Current dir hints content"));
assert!(!hints.contains("Base hints content"));
assert!(!hints.contains("Subdir hints content"));
}
#[test]
fn test_nested_goosehints_mixed_filenames() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
fs::create_dir(project_root.join(".git")).unwrap();
fs::write(project_root.join("CLAUDE.md"), "Root CLAUDE.md content").unwrap();
let subdir = project_root.join("subdir");
fs::create_dir(&subdir).unwrap();
fs::write(
subdir.join(GOOSE_HINTS_FILENAME),
"Subdir .goosehints content",
)
.unwrap();
let current_dir = subdir.join("current_dir");
fs::create_dir(&current_dir).unwrap();
let gitignore = create_dummy_gitignore();
let hints = load_hint_files(
&current_dir,
&["CLAUDE.md".to_string(), GOOSE_HINTS_FILENAME.to_string()],
&gitignore,
);
assert!(hints.contains("Root CLAUDE.md content"));
assert!(hints.contains("Subdir .goosehints content"));
}
#[test]
fn test_hints_with_basic_imports() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
fs::create_dir(project_root.join(".git")).unwrap();
fs::write(project_root.join("README.md"), "# Project README").unwrap();
fs::write(project_root.join("config.md"), "Configuration details").unwrap();
let hints_content = r#"Project hints content
@README.md
@config.md
Additional instructions here."#;
fs::write(project_root.join(GOOSE_HINTS_FILENAME), hints_content).unwrap();
let gitignore = create_dummy_gitignore();
let hints = load_hint_files(
project_root,
&[GOOSE_HINTS_FILENAME.to_string()],
&gitignore,
);
assert!(hints.contains("Project hints content"));
assert!(hints.contains("Additional instructions here"));
assert!(hints.contains("--- Content from README.md ---"));
assert!(hints.contains("# Project README"));
assert!(hints.contains("--- End of README.md ---"));
assert!(hints.contains("--- Content from config.md ---"));
assert!(hints.contains("Configuration details"));
assert!(hints.contains("--- End of config.md ---"));
}
#[test]
fn test_hints_with_git_import_boundary() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
fs::create_dir(project_root.join(".git")).unwrap();
fs::write(project_root.join("root_file.md"), "Root file content").unwrap();
fs::write(
project_root.join("shared_docs.md"),
"Shared documentation content",
)
.unwrap();
let docs_dir = project_root.join("docs");
fs::create_dir_all(&docs_dir).unwrap();
fs::write(docs_dir.join("api.md"), "API documentation content").unwrap();
let utils_dir = project_root.join("src").join("utils");
fs::create_dir_all(&utils_dir).unwrap();
fs::write(
utils_dir.join("helpers.md"),
"Helper utilities content @../../shared_docs.md",
)
.unwrap();
let components_dir = project_root.join("src").join("components");
fs::create_dir_all(&components_dir).unwrap();
fs::write(components_dir.join("local_file.md"), "Local file content").unwrap();
let outside_dir = temp_dir.path().parent().unwrap();
fs::write(outside_dir.join("forbidden.md"), "Forbidden content").unwrap();
let root_hints_content = r#"Project root hints
@docs/api.md
Root level instructions"#;
fs::write(project_root.join(GOOSE_HINTS_FILENAME), root_hints_content).unwrap();
let nested_hints_content = r#"Nested directory hints
@local_file.md
@../utils/helpers.md
@../../docs/api.md
@../../root_file.md
@../../../forbidden.md
End of nested hints"#;
fs::write(
components_dir.join(GOOSE_HINTS_FILENAME),
nested_hints_content,
)
.unwrap();
let gitignore = create_dummy_gitignore();
let hints = load_hint_files(
&components_dir,
&[GOOSE_HINTS_FILENAME.to_string()],
&gitignore,
);
println!("======{}", hints);
assert!(hints.contains("Project root hints"));
assert!(hints.contains("Root level instructions"));
assert!(hints.contains("API documentation content"));
assert!(hints.contains("--- Content from docs/api.md ---"));
assert!(hints.contains("Nested directory hints"));
assert!(hints.contains("End of nested hints"));
assert!(hints.contains("Local file content"));
assert!(hints.contains("--- Content from local_file.md ---"));
assert!(hints.contains("Helper utilities content"));
assert!(hints.contains("--- Content from ../utils/helpers.md ---"));
assert!(hints.contains("Shared documentation content"));
assert!(hints.contains("--- Content from ../../shared_docs.md ---"));
let api_content_count = hints.matches("API documentation content").count();
assert_eq!(
api_content_count, 2,
"API content should appear twice - from root and nested hints"
);
assert!(hints.contains("Root file content"));
assert!(hints.contains("--- Content from ../../root_file.md ---"));
assert!(!hints.contains("Forbidden content"));
assert!(hints.contains("@../../../forbidden.md"));
}
#[test]
fn test_hints_without_git_import_boundary() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let current_dir = base_dir.join("current");
fs::create_dir(&current_dir).unwrap();
fs::write(current_dir.join("local.md"), "Local content").unwrap();
fs::write(base_dir.join("parent.md"), "Parent content").unwrap();
let hints_content = r#"Current directory hints
@local.md
@../parent.md
End of hints"#;
fs::write(current_dir.join(GOOSE_HINTS_FILENAME), hints_content).unwrap();
let gitignore = create_dummy_gitignore();
let hints = load_hint_files(
&current_dir,
&[GOOSE_HINTS_FILENAME.to_string()],
&gitignore,
);
assert!(hints.contains("Local content"));
assert!(hints.contains("--- Content from local.md ---"));
assert!(!hints.contains("Parent content"));
assert!(hints.contains("@../parent.md"));
}
#[test]
fn test_import_boundary_respects_nested_setting() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
fs::create_dir(project_root.join(".git")).unwrap();
fs::write(project_root.join("root_file.md"), "Root file content").unwrap();
let subdir = project_root.join("subdir");
fs::create_dir(&subdir).unwrap();
fs::write(subdir.join("local_file.md"), "Local file content").unwrap();
let hints_content = r#"Subdir hints
@local_file.md
@../root_file.md
End of hints"#;
fs::write(subdir.join(GOOSE_HINTS_FILENAME), hints_content).unwrap();
let gitignore = create_dummy_gitignore();
let hints = load_hint_files(&subdir, &[GOOSE_HINTS_FILENAME.to_string()], &gitignore);
assert!(hints.contains("Local file content"));
assert!(hints.contains("--- Content from local_file.md ---"));
assert!(hints.contains("Root file content"));
assert!(hints.contains("--- Content from ../root_file.md ---"));
}
}
+4
View File
@@ -0,0 +1,4 @@
mod import_files;
pub mod load_hints;
pub use load_hints::{load_hint_files, AGENTS_MD_FILENAME, GOOSE_HINTS_FILENAME};