use crate::serde_yaml;
use std::path::Path;
use globset::GlobBuilder;
use ignore::WalkBuilder;
use rustc_hash::FxHashMap;
use serde_json::Value;
use crate::ast::context::ContextConfig;
use crate::error::NikaError;
fn validate_path_boundary(base_path: &Path, target_path: &Path) -> Result<(), NikaError> {
let canonical_base = base_path
.canonicalize()
.unwrap_or_else(|_| base_path.to_path_buf());
let canonical_target = target_path
.canonicalize()
.map_err(|e| NikaError::ContextLoadError {
alias: String::new(),
path: target_path.display().to_string(),
reason: format!("Cannot resolve path: {}", e),
})?;
if !canonical_target.starts_with(&canonical_base) {
return Err(NikaError::ContextLoadError {
alias: String::new(),
path: target_path.display().to_string(),
reason: format!(
"Path traversal detected: '{}' is outside project boundary '{}'",
target_path.display(),
base_path.display()
),
});
}
Ok(())
}
#[derive(Debug, Clone, Default)]
pub struct LoadedContext {
pub files: FxHashMap<String, Value>,
pub session: Option<Value>,
}
impl LoadedContext {
pub fn new() -> Self {
Self::default()
}
pub fn get_file(&self, alias: &str) -> Option<&Value> {
self.files.get(alias)
}
pub fn get_session(&self) -> Option<&Value> {
self.session.as_ref()
}
pub fn is_empty(&self) -> bool {
self.files.is_empty() && self.session.is_none()
}
pub fn file_count(&self) -> usize {
self.files.len()
}
}
pub async fn load_context(
config: &ContextConfig,
base_path: &Path,
) -> Result<LoadedContext, NikaError> {
let mut context = LoadedContext::new();
for (alias, path_pattern) in &config.files {
let value = if is_glob_pattern(path_pattern) {
load_glob_files(path_pattern, base_path).await?
} else {
let full_path = base_path.join(path_pattern);
validate_path_boundary(base_path, &full_path)?;
load_single_file(&full_path).await?
};
context.files.insert(alias.to_string(), value);
}
if let Some(session_path) = &config.session {
let full_path = base_path.join(session_path);
if full_path.exists() {
validate_path_boundary(base_path, &full_path)?;
let content = tokio::fs::read_to_string(&full_path).await.map_err(|e| {
NikaError::ContextLoadError {
alias: "session".to_string(),
path: full_path.display().to_string(),
reason: e.to_string(),
}
})?;
let session: Value =
serde_json::from_str(&content).map_err(|e| NikaError::ContextLoadError {
alias: "session".to_string(),
path: full_path.display().to_string(),
reason: format!("Invalid JSON: {}", e),
})?;
context.session = Some(session);
}
}
Ok(context)
}
fn is_glob_pattern(pattern: &str) -> bool {
pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
}
async fn load_single_file(path: &Path) -> Result<Value, NikaError> {
let content =
tokio::fs::read_to_string(path)
.await
.map_err(|e| NikaError::ContextLoadError {
alias: String::new(),
path: path.display().to_string(),
reason: e.to_string(),
})?;
match path.extension().and_then(|e| e.to_str()) {
Some("json") => serde_json::from_str(&content).map_err(|e| NikaError::ContextLoadError {
alias: String::new(),
path: path.display().to_string(),
reason: format!("Invalid JSON: {}", e),
}),
Some("yaml") | Some("yml") => {
serde_yaml::from_str(&content).map_err(|e| NikaError::ContextLoadError {
alias: String::new(),
path: path.display().to_string(),
reason: format!("Invalid YAML: {}", e),
})
}
_ => Ok(Value::String(content)),
}
}
async fn load_glob_files(pattern: &str, base_path: &Path) -> Result<Value, NikaError> {
let pattern_path = Path::new(pattern);
let parent = pattern_path.parent().unwrap_or(Path::new("."));
let file_pattern = pattern_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("*");
let search_dir = base_path.join(parent);
if search_dir.exists() {
validate_path_boundary(base_path, &search_dir)?;
}
let glob = GlobBuilder::new(file_pattern)
.literal_separator(true)
.build()
.map_err(|e| NikaError::ContextLoadError {
alias: String::new(),
path: pattern.to_string(),
reason: format!("Invalid glob pattern: {}", e),
})?
.compile_matcher();
let mut results = Vec::new();
if !search_dir.exists() {
return Ok(Value::Array(Vec::new()));
}
let walker = WalkBuilder::new(&search_dir)
.hidden(false)
.max_depth(Some(1)) .build();
for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if glob.is_match(file_name) {
let content =
tokio::fs::read_to_string(path)
.await
.map_err(|e| NikaError::ContextLoadError {
alias: String::new(),
path: path.display().to_string(),
reason: e.to_string(),
})?;
results.push(Value::String(content));
}
}
Ok(Value::Array(results))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tokio::fs;
#[test]
fn test_loaded_context_default() {
let context = LoadedContext::default();
assert!(context.is_empty());
assert_eq!(context.file_count(), 0);
}
#[test]
fn test_loaded_context_get_file() {
let mut context = LoadedContext::new();
context
.files
.insert("test".to_string(), Value::String("content".to_string()));
assert!(context.get_file("test").is_some());
assert!(context.get_file("nonexistent").is_none());
}
#[test]
fn test_is_glob_pattern() {
assert!(is_glob_pattern("*.md"));
assert!(is_glob_pattern("**/*.rs"));
assert!(is_glob_pattern("file?.txt"));
assert!(is_glob_pattern("[abc].txt"));
assert!(!is_glob_pattern("file.txt"));
assert!(!is_glob_pattern("./context/brand.md"));
}
#[tokio::test]
async fn test_load_single_file_text() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.md");
fs::write(&file_path, "# Hello World").await.unwrap();
let result = load_single_file(&file_path).await.unwrap();
assert_eq!(result, Value::String("# Hello World".to_string()));
}
#[tokio::test]
async fn test_load_single_file_json() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.json");
fs::write(&file_path, r#"{"name": "test", "value": 42}"#)
.await
.unwrap();
let result = load_single_file(&file_path).await.unwrap();
assert!(result.is_object());
assert_eq!(result["name"], "test");
assert_eq!(result["value"], 42);
}
#[tokio::test]
async fn test_load_single_file_yaml() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.yaml");
fs::write(&file_path, "name: test\nvalue: 42")
.await
.unwrap();
let result = load_single_file(&file_path).await.unwrap();
assert!(result.is_object());
assert_eq!(result["name"], "test");
assert_eq!(result["value"], 42);
}
#[tokio::test]
async fn test_load_glob_files() {
let temp_dir = TempDir::new().unwrap();
let context_dir = temp_dir.path().join("context");
fs::create_dir(&context_dir).await.unwrap();
fs::write(context_dir.join("file1.md"), "# File 1")
.await
.unwrap();
fs::write(context_dir.join("file2.md"), "# File 2")
.await
.unwrap();
fs::write(context_dir.join("other.txt"), "Other file")
.await
.unwrap();
let result = load_glob_files("context/*.md", temp_dir.path())
.await
.unwrap();
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 2);
}
#[tokio::test]
async fn test_load_context_full() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("brand.md"), "# Brand Guide")
.await
.unwrap();
fs::write(temp_dir.path().join("persona.json"), r#"{"name": "Agent"}"#)
.await
.unwrap();
let config = ContextConfig {
files: {
let mut m = FxHashMap::default();
m.insert("brand".to_string(), "brand.md".to_string());
m.insert("persona".to_string(), "persona.json".to_string());
m
},
session: None,
};
let context = load_context(&config, temp_dir.path()).await.unwrap();
assert_eq!(context.file_count(), 2);
assert!(context.get_file("brand").is_some());
assert!(context.get_file("persona").is_some());
}
#[tokio::test]
async fn test_load_context_with_session() {
let temp_dir = TempDir::new().unwrap();
let sessions_dir = temp_dir.path().join(".nika/sessions");
fs::create_dir_all(&sessions_dir).await.unwrap();
fs::write(
sessions_dir.join("prev.json"),
r#"{"focus_areas": ["rust", "ai"]}"#,
)
.await
.unwrap();
let config = ContextConfig {
files: FxHashMap::default(),
session: Some(".nika/sessions/prev.json".to_string()),
};
let context = load_context(&config, temp_dir.path()).await.unwrap();
assert!(context.session.is_some());
let session = context.session.as_ref().unwrap();
assert!(session["focus_areas"].is_array());
}
#[tokio::test]
async fn test_load_context_missing_session_ok() {
let temp_dir = TempDir::new().unwrap();
let config = ContextConfig {
files: FxHashMap::default(),
session: Some(".nika/sessions/nonexistent.json".to_string()),
};
let context = load_context(&config, temp_dir.path()).await.unwrap();
assert!(context.session.is_none());
}
#[tokio::test]
async fn test_load_context_missing_file_error() {
let temp_dir = TempDir::new().unwrap();
let config = ContextConfig {
files: {
let mut m = FxHashMap::default();
m.insert("missing".to_string(), "nonexistent.md".to_string());
m
},
session: None,
};
let result = load_context(&config, temp_dir.path()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_load_context_path_traversal_detection() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("project");
fs::create_dir(&project_dir).await.unwrap();
fs::write(temp_dir.path().join("secret.md"), "# Secret content")
.await
.unwrap();
let config = ContextConfig {
files: {
let mut m = FxHashMap::default();
m.insert("secret".to_string(), "../secret.md".to_string());
m
},
session: None,
};
let result = load_context(&config, &project_dir).await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("Path traversal") || err_str.contains("outside project"),
"Expected path traversal error, got: {}",
err_str
);
}
#[test]
fn test_validate_path_boundary() {
let temp_dir = TempDir::new().unwrap();
let project = temp_dir.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let valid_file = project.join("valid.md");
std::fs::write(&valid_file, "test").unwrap();
let outside_file = temp_dir.path().join("outside.md");
std::fs::write(&outside_file, "test").unwrap();
assert!(validate_path_boundary(&project, &valid_file).is_ok());
let result = validate_path_boundary(&project, &outside_file);
assert!(result.is_err());
}
#[tokio::test]
async fn test_load_single_file_invalid_json() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("invalid.json");
fs::write(&file_path, "{ not valid json ]").await.unwrap();
let result = load_single_file(&file_path).await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("Invalid JSON"),
"Expected Invalid JSON error, got: {}",
err_str
);
}
#[tokio::test]
async fn test_load_single_file_invalid_yaml() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("invalid.yaml");
fs::write(&file_path, "name: test\n bad: indent\n key: value")
.await
.unwrap();
let result = load_single_file(&file_path).await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("Invalid YAML"),
"Expected Invalid YAML error, got: {}",
err_str
);
}
#[tokio::test]
async fn test_load_single_file_not_found() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("nonexistent.txt");
let result = load_single_file(&file_path).await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("No such file") || err_str.contains("nonexistent"),
"Expected file not found error, got: {}",
err_str
);
}
#[tokio::test]
async fn test_load_context_invalid_session_json() {
let temp_dir = TempDir::new().unwrap();
let sessions_dir = temp_dir.path().join(".nika/sessions");
fs::create_dir_all(&sessions_dir).await.unwrap();
fs::write(sessions_dir.join("bad.json"), "{ invalid json }")
.await
.unwrap();
let config = ContextConfig {
files: FxHashMap::default(),
session: Some(".nika/sessions/bad.json".to_string()),
};
let result = load_context(&config, temp_dir.path()).await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("Invalid JSON"),
"Expected Invalid JSON error for session, got: {}",
err_str
);
}
#[tokio::test]
async fn test_load_glob_files_nonexistent_directory() {
let temp_dir = TempDir::new().unwrap();
let result = load_glob_files("nonexistent_dir/*.md", temp_dir.path()).await;
assert!(result.is_ok());
let arr = result.unwrap();
assert!(arr.is_array());
assert_eq!(arr.as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn test_load_glob_files_no_matches() {
let temp_dir = TempDir::new().unwrap();
let context_dir = temp_dir.path().join("context");
fs::create_dir(&context_dir).await.unwrap();
fs::write(context_dir.join("file.txt"), "content")
.await
.unwrap();
let result = load_glob_files("context/*.md", temp_dir.path()).await;
assert!(result.is_ok());
let arr = result.unwrap().as_array().unwrap().clone();
assert_eq!(arr.len(), 0);
}
#[test]
fn test_validate_path_boundary_nonexistent_target() {
let temp_dir = TempDir::new().unwrap();
let project = temp_dir.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let nonexistent = project.join("does_not_exist.txt");
let result = validate_path_boundary(&project, &nonexistent);
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("Cannot resolve path"),
"Expected cannot resolve error, got: {}",
err_str
);
}
#[tokio::test]
async fn test_load_context_error_contains_alias() {
let temp_dir = TempDir::new().unwrap();
let config = ContextConfig {
files: {
let mut m = FxHashMap::default();
m.insert("my_alias".to_string(), "nonexistent.md".to_string());
m
},
session: None,
};
let result = load_context(&config, temp_dir.path()).await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("nonexistent.md"),
"Error should include file path, got: {}",
err_str
);
}
}