use crate::logging::Logger;
use crate::types::Priority;
use anyhow::{Context, Result};
use blake3::Hasher;
use camino::{Utf8Path, Utf8PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
#[derive(Debug)]
pub struct InsightCache {
cache_dir: Utf8PathBuf,
memory_cache: HashMap<String, CachedInsight>,
stats: CacheStats,
}
#[derive(Debug, Default, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub struct CacheStats {
pub hits: usize,
pub misses: usize,
pub invalidations: usize,
pub writes: usize,
}
impl CacheStats {
#[must_use]
pub fn hit_ratio(&self) -> f64 {
let total = self.hits + self.misses;
if total == 0 {
0.0
} else {
self.hits as f64 / total as f64
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedInsight {
pub content_hash: String,
pub file_path: String,
pub priority: Priority,
pub insights: Vec<String>,
pub phase: String,
pub cached_at: DateTime<Utc>,
pub file_size: u64,
pub last_modified: DateTime<Utc>,
}
impl InsightCache {
pub fn new(cache_dir: Utf8PathBuf) -> Result<Self> {
crate::paths::ensure_dir_all(&cache_dir)
.with_context(|| format!("Failed to create cache directory: {cache_dir}"))?;
Ok(Self {
cache_dir,
memory_cache: HashMap::new(),
stats: CacheStats::default(),
})
}
#[must_use]
pub const fn stats(&self) -> &CacheStats {
&self.stats
}
fn cache_key(&self, content_hash: &str, phase: &str) -> String {
format!("{content_hash}_{phase}")
}
fn cache_file_path(&self, key: &str) -> Utf8PathBuf {
self.cache_dir.join(format!("{key}.json"))
}
fn has_file_changed(
&self,
file_path: &Utf8Path,
cached_insight: &CachedInsight,
) -> Result<bool> {
let metadata = fs::metadata(file_path)
.with_context(|| format!("Failed to get metadata for file: {file_path}"))?;
let current_size = metadata.len();
let current_modified = DateTime::<Utc>::from(
metadata
.modified()
.with_context(|| format!("Failed to get modified time for file: {file_path}"))?,
);
Ok(current_size != cached_insight.file_size
|| current_modified != cached_insight.last_modified)
}
pub fn get_insights(
&mut self,
file_path: &Utf8Path,
content_hash: &str,
phase: &str,
logger: Option<&Logger>,
) -> Result<Option<Vec<String>>> {
let key = self.cache_key(content_hash, phase);
if let Some(cached) = self.memory_cache.get(&key) {
if self.has_file_changed(file_path, cached)? {
self.memory_cache.remove(&key);
self.stats.invalidations += 1;
if let Some(logger) = logger {
logger.verbose(&format!(
"Cache invalidated (file changed): {} [{}]",
file_path,
&content_hash[..8]
));
}
} else {
self.stats.hits += 1;
if let Some(logger) = logger {
logger.verbose(&format!(
"Cache hit (memory): {} [{}]",
file_path,
&content_hash[..8]
));
}
return Ok(Some(cached.insights.clone()));
}
}
let cache_file = self.cache_file_path(&key);
if cache_file.exists() {
if let Ok(cached) = self.load_cached_insight(&cache_file) {
if cached.content_hash == content_hash {
if self.has_file_changed(file_path, &cached)? {
let _ = fs::remove_file(&cache_file);
self.stats.invalidations += 1;
if let Some(logger) = logger {
logger.verbose(&format!(
"Cache invalidated (file changed): {} [{}]",
file_path,
&content_hash[..8]
));
}
} else {
self.memory_cache.insert(key, cached.clone());
self.stats.hits += 1;
if let Some(logger) = logger {
logger.verbose(&format!(
"Cache hit (disk): {} [{}]",
file_path,
&content_hash[..8]
));
}
return Ok(Some(cached.insights));
}
} else {
let _ = fs::remove_file(&cache_file);
self.stats.invalidations += 1;
if let Some(logger) = logger {
logger.verbose(&format!(
"Cache invalidated (hash mismatch): {} [{}]",
file_path,
&content_hash[..8]
));
}
}
} else {
let _ = fs::remove_file(&cache_file);
if let Some(logger) = logger {
logger.verbose(&format!("Cache file corrupted, removed: {cache_file}"));
}
}
}
self.stats.misses += 1;
if let Some(logger) = logger {
logger.verbose(&format!(
"Cache miss: {} [{}]",
file_path,
&content_hash[..8]
));
}
Ok(None)
}
#[allow(clippy::too_many_arguments)]
pub fn store_insights(
&mut self,
file_path: &Utf8Path,
_content: &str,
content_hash: &str,
phase: &str,
priority: Priority,
insights: Vec<String>,
logger: Option<&Logger>,
) -> Result<()> {
let key = self.cache_key(content_hash, phase);
let metadata = fs::metadata(file_path)
.with_context(|| format!("Failed to get metadata for file: {file_path}"))?;
let cached_insight =
CachedInsight {
content_hash: content_hash.to_string(),
file_path: file_path.to_string(),
priority,
insights: insights.clone(),
phase: phase.to_string(),
cached_at: Utc::now(),
file_size: metadata.len(),
last_modified: DateTime::<Utc>::from(metadata.modified().with_context(|| {
format!("Failed to get modified time for file: {file_path}")
})?),
};
self.memory_cache
.insert(key.clone(), cached_insight.clone());
let cache_file = self.cache_file_path(&key);
self.save_cached_insight(&cache_file, &cached_insight)?;
self.stats.writes += 1;
if let Some(logger) = logger {
logger.verbose(&format!(
"Cached insights: {} ({} insights) [{}]",
file_path,
insights.len(),
&content_hash[..8]
));
}
Ok(())
}
#[must_use]
pub fn generate_insights(
&self,
content: &str,
file_path: &Utf8Path,
phase: &str,
priority: Priority,
) -> Vec<String> {
let mut insights = Vec::new();
let line_count = content.lines().count();
let byte_count = content.len();
insights.push(format!(
"File: {file_path} ({line_count} lines, {byte_count} bytes)"
));
insights.push(format!("Priority: {priority:?}"));
match phase.to_lowercase().as_str() {
"requirements" => {
self.generate_requirements_insights(content, &mut insights);
}
"design" => {
self.generate_design_insights(content, &mut insights);
}
"tasks" => {
self.generate_tasks_insights(content, &mut insights);
}
"review" => {
self.generate_review_insights(content, &mut insights);
}
_ => {
self.generate_generic_insights(content, &mut insights);
}
}
let current_len = insights.len();
if current_len < 10 {
self.add_generic_content_insights(content, &mut insights, 10 - current_len);
} else if insights.len() > 25 {
insights.truncate(25);
}
insights
}
fn generate_requirements_insights(&self, content: &str, insights: &mut Vec<String>) {
let user_story_count = content.matches("As a").count();
if user_story_count > 0 {
insights.push(format!("Contains {user_story_count} user stories"));
}
let acceptance_criteria_count =
content.matches("WHEN").count() + content.matches("THEN").count();
if acceptance_criteria_count > 0 {
insights.push(format!(
"Contains {acceptance_criteria_count} acceptance criteria statements"
));
}
if content.contains("## Requirements") || content.contains("# Requirements") {
insights.push("Contains structured requirements section".to_string());
}
if content.contains("Non-Functional") || content.contains("NFR") {
insights.push("Includes non-functional requirements".to_string());
}
let req_numbers = content.matches("Requirement ").count();
if req_numbers > 0 {
insights.push(format!("Defines {req_numbers} numbered requirements"));
}
}
fn generate_design_insights(&self, content: &str, insights: &mut Vec<String>) {
if content.contains("## Architecture") || content.contains("# Architecture") {
insights.push("Contains architecture section".to_string());
}
if content.contains("Component") || content.contains("component") {
let component_count = content.matches("component").count();
insights.push(format!("References {component_count} components"));
}
if content.contains("interface") || content.contains("Interface") {
insights.push("Describes interfaces".to_string());
}
if content.contains("Data Model") || content.contains("data model") {
insights.push("Includes data model definitions".to_string());
}
if content.contains("```mermaid") || content.contains("```plantuml") {
let diagram_count =
content.matches("```mermaid").count() + content.matches("```plantuml").count();
insights.push(format!("Contains {diagram_count} diagrams"));
}
if content.contains("Error") || content.contains("error") {
insights.push("Addresses error handling".to_string());
}
}
fn generate_tasks_insights(&self, content: &str, insights: &mut Vec<String>) {
let task_count = content.matches("- [ ]").count() + content.matches("- [x]").count();
if task_count > 0 {
insights.push(format!("Contains {task_count} tasks"));
}
let completed_count = content.matches("- [x]").count();
if completed_count > 0 {
insights.push(format!("{completed_count} tasks completed"));
}
let milestone_count = content.matches("Milestone").count();
if milestone_count > 0 {
insights.push(format!("Organized into {milestone_count} milestones"));
}
if content.contains("Phase") || content.contains("phase") {
insights.push("Includes phased implementation approach".to_string());
}
if content.contains("test") || content.contains("Test") {
let test_count = content.matches("test").count();
insights.push(format!("Includes {test_count} testing-related items"));
}
}
fn generate_review_insights(&self, content: &str, insights: &mut Vec<String>) {
if content.contains("FIXUP") || content.contains("fixup") {
insights.push("Contains fixup recommendations".to_string());
}
if content.contains("feedback") || content.contains("Feedback") {
insights.push("Includes feedback items".to_string());
}
if content.contains("issue") || content.contains("Issue") || content.contains("problem") {
insights.push("Identifies issues or problems".to_string());
}
if content.contains("recommend") || content.contains("Recommend") {
insights.push("Contains recommendations".to_string());
}
}
fn generate_generic_insights(&self, content: &str, insights: &mut Vec<String>) {
let section_count = content.matches("##").count() + content.matches('#').count();
if section_count > 0 {
insights.push(format!("Contains {section_count} sections"));
}
let code_block_count = content.matches("```").count() / 2; if code_block_count > 0 {
insights.push(format!("Contains {code_block_count} code blocks"));
}
let link_count = content.matches("](").count();
if link_count > 0 {
insights.push(format!("Contains {link_count} links"));
}
let list_item_count = content.matches("- ").count() + content.matches("* ").count();
if list_item_count > 0 {
insights.push(format!("Contains {list_item_count} list items"));
}
}
fn add_generic_content_insights(
&self,
content: &str,
insights: &mut Vec<String>,
needed: usize,
) {
let mut added = 0;
if added < needed {
let word_count = content.split_whitespace().count();
insights.push(format!("Word count: {word_count}"));
added += 1;
}
if added < needed {
let paragraph_count = content.split("\n\n").count();
insights.push(format!("Paragraph count: {paragraph_count}"));
added += 1;
}
if added < needed {
let char_count = content.chars().count();
insights.push(format!("Character count: {char_count}"));
added += 1;
}
if added < needed {
let empty_lines = content
.lines()
.filter(|line| line.trim().is_empty())
.count();
insights.push(format!("Empty lines: {empty_lines}"));
added += 1;
}
if added < needed {
if content.contains("```rust") {
insights.push("Contains Rust code".to_string());
added += 1;
} else if content.contains("```yaml") || content.contains("```yml") {
insights.push("Contains YAML content".to_string());
added += 1;
} else if content.contains("```json") {
insights.push("Contains JSON content".to_string());
added += 1;
} else if content.contains("```toml") {
insights.push("Contains TOML content".to_string());
added += 1;
}
}
while added < needed && insights.len() < 25 {
match added {
0 => insights.push("Content analysis complete".to_string()),
1 => insights.push("Structured document format".to_string()),
2 => insights.push("Text-based content".to_string()),
3 => insights.push("UTF-8 encoded content".to_string()),
4 => insights.push("Markdown formatting detected".to_string()),
_ => insights.push(format!("Additional insight #{}", added + 1)),
}
added += 1;
}
}
fn load_cached_insight(&self, cache_file: &Utf8Path) -> Result<CachedInsight> {
let content = fs::read_to_string(cache_file)
.with_context(|| format!("Failed to read cache file: {cache_file}"))?;
let cached: CachedInsight = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse cache file: {cache_file}"))?;
Ok(cached)
}
fn save_cached_insight(&self, cache_file: &Utf8Path, cached: &CachedInsight) -> Result<()> {
let content =
serde_json::to_string_pretty(cached).context("Failed to serialize cached insight")?;
fs::write(cache_file, content)
.with_context(|| format!("Failed to write cache file: {cache_file}"))?;
Ok(())
}
#[allow(dead_code)] pub fn clear(&mut self) -> Result<()> {
self.memory_cache.clear();
if self.cache_dir.exists() {
for entry in fs::read_dir(&self.cache_dir)? {
let entry = entry?;
if entry.path().extension().and_then(|s| s.to_str()) == Some("json") {
fs::remove_file(entry.path())?;
}
}
}
self.stats = CacheStats::default();
Ok(())
}
#[allow(dead_code)] pub fn log_stats(&self, logger: &Logger) {
if self.stats.hits + self.stats.misses > 0 {
logger.verbose(&format!(
"Cache stats: {} hits, {} misses ({:.1}% hit rate), {} invalidations, {} writes",
self.stats.hits,
self.stats.misses,
self.stats.hit_ratio() * 100.0,
self.stats.invalidations,
self.stats.writes
));
}
}
}
#[must_use]
pub fn calculate_content_hash(content: &str) -> String {
let mut hasher = Hasher::new();
hasher.update(content.as_bytes());
hasher.finalize().to_hex().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
use tempfile::TempDir;
#[test]
fn test_cache_creation() -> Result<()> {
let temp_dir = TempDir::new()?;
let cache_dir = Utf8PathBuf::try_from(temp_dir.path().to_path_buf())?;
let cache = InsightCache::new(cache_dir.clone())?;
assert!(cache_dir.exists());
assert_eq!(cache.stats().hits, 0);
assert_eq!(cache.stats().misses, 0);
Ok(())
}
#[test]
fn test_cache_miss_and_store() -> Result<()> {
let temp_dir = TempDir::new()?;
let cache_dir = Utf8PathBuf::try_from(temp_dir.path().to_path_buf())?;
let mut cache = InsightCache::new(cache_dir)?;
let test_file = temp_dir.path().join("test.md");
let content = "# Test\nThis is test content.";
fs::write(&test_file, content)?;
let file_path = Utf8PathBuf::try_from(test_file)?;
let content_hash = calculate_content_hash(content);
let result = cache.get_insights(&file_path, &content_hash, "requirements", None)?;
assert!(result.is_none());
assert_eq!(cache.stats().misses, 1);
let insights =
cache.generate_insights(content, &file_path, "requirements", Priority::Medium);
assert!(insights.len() >= 10);
assert!(insights.len() <= 25);
cache.store_insights(
&file_path,
content,
&content_hash,
"requirements",
Priority::Medium,
insights.clone(),
None,
)?;
assert_eq!(cache.stats().writes, 1);
let cached_insights =
cache.get_insights(&file_path, &content_hash, "requirements", None)?;
assert!(cached_insights.is_some());
assert_eq!(cached_insights.unwrap(), insights);
assert_eq!(cache.stats().hits, 1);
Ok(())
}
#[test]
fn test_cache_invalidation_on_file_change() -> Result<()> {
let temp_dir = TempDir::new()?;
let cache_dir = Utf8PathBuf::try_from(temp_dir.path().to_path_buf())?;
let mut cache = InsightCache::new(cache_dir)?;
let test_file = temp_dir.path().join("test.md");
let content1 = "# Test\nOriginal content.";
fs::write(&test_file, content1)?;
let file_path = Utf8PathBuf::try_from(test_file.clone())?;
let content_hash1 = calculate_content_hash(content1);
let insights1 =
cache.generate_insights(content1, &file_path, "requirements", Priority::Medium);
cache.store_insights(
&file_path,
content1,
&content_hash1,
"requirements",
Priority::Medium,
insights1,
None,
)?;
let cached = cache.get_insights(&file_path, &content_hash1, "requirements", None)?;
assert!(cached.is_some());
thread::sleep(Duration::from_millis(10));
let content2 = "# Test\nModified content.";
fs::write(&test_file, content2)?;
let content_hash2 = calculate_content_hash(content2);
let result = cache.get_insights(&file_path, &content_hash1, "requirements", None)?;
assert!(result.is_none());
assert!(cache.stats().invalidations > 0);
let result = cache.get_insights(&file_path, &content_hash2, "requirements", None)?;
assert!(result.is_none());
Ok(())
}
#[test]
fn test_disk_cache_persistence() -> Result<()> {
let temp_dir = TempDir::new()?;
let cache_dir = Utf8PathBuf::try_from(temp_dir.path().to_path_buf())?;
let test_file = temp_dir.path().join("test.md");
let content = "# Test\nPersistent content.";
fs::write(&test_file, content)?;
let file_path = Utf8PathBuf::try_from(test_file)?;
let content_hash = calculate_content_hash(content);
let insights = vec!["Test insight 1".to_string(), "Test insight 2".to_string()];
{
let mut cache1 = InsightCache::new(cache_dir.clone())?;
cache1.store_insights(
&file_path,
content,
&content_hash,
"requirements",
Priority::Medium,
insights.clone(),
None,
)?;
}
{
let mut cache2 = InsightCache::new(cache_dir)?;
let cached_insights =
cache2.get_insights(&file_path, &content_hash, "requirements", None)?;
assert!(cached_insights.is_some());
assert_eq!(cached_insights.unwrap(), insights);
assert_eq!(cache2.stats().hits, 1);
}
Ok(())
}
#[test]
fn test_insight_generation_requirements() {
let cache = InsightCache::new(Utf8PathBuf::from("/tmp")).unwrap();
let content = r"
# Requirements Document
## Requirements
### Requirement 1
**User Story:** As a developer, I want to test, so that I can verify functionality.
#### Acceptance Criteria
1. WHEN I run tests THEN the system SHALL pass
2. WHEN errors occur THEN the system SHALL report them
### Requirement 2
**User Story:** As a user, I want features, so that I can be productive.
#### Acceptance Criteria
1. WHEN I use features THEN they SHALL work
";
let insights = cache.generate_insights(
content,
Utf8Path::new("requirements.md"),
"requirements",
Priority::High,
);
assert!(insights.len() >= 10);
assert!(insights.len() <= 25);
let insights_text = insights.join(" ");
assert!(insights_text.contains("user stories") || insights_text.contains("User Story"));
assert!(
insights_text.contains("acceptance criteria")
|| insights_text.contains("WHEN")
|| insights_text.contains("THEN")
);
}
#[test]
fn test_insight_generation_design() {
let cache = InsightCache::new(Utf8PathBuf::from("/tmp")).unwrap();
let content = r"
# Design Document
## Architecture
The system consists of multiple components that interact through well-defined interfaces.
## Components
### Component A
This component handles data processing.
### Component B
This component manages the user interface.
## Data Models
```rust
struct User {
id: u32,
name: String,
}
```
## Error Handling
The system handles errors gracefully through a structured error hierarchy.
";
let insights = cache.generate_insights(
content,
Utf8Path::new("design.md"),
"design",
Priority::High,
);
assert!(insights.len() >= 10);
assert!(insights.len() <= 25);
let insights_text = insights.join(" ");
assert!(insights_text.contains("Architecture") || insights_text.contains("architecture"));
assert!(insights_text.contains("component") || insights_text.contains("Component"));
assert!(insights_text.contains("Error") || insights_text.contains("error"));
}
#[test]
fn test_cache_key_generation() {
let cache = InsightCache::new(Utf8PathBuf::from("/tmp")).unwrap();
let key1 = cache.cache_key("hash123", "requirements");
let key2 = cache.cache_key("hash123", "design");
let key3 = cache.cache_key("hash456", "requirements");
assert_ne!(key1, key2); assert_ne!(key1, key3); assert_ne!(key2, key3);
assert!(key1.contains("hash123"));
assert!(key1.contains("requirements"));
}
#[test]
fn test_cache_stats() {
let cache_dir = Utf8PathBuf::from("/tmp/test_cache");
let mut cache = InsightCache::new(cache_dir).unwrap();
assert_eq!(cache.stats().hit_ratio(), 0.0);
cache.stats.hits = 8;
cache.stats.misses = 2;
cache.stats.writes = 2;
cache.stats.invalidations = 1;
assert_eq!(cache.stats().hit_ratio(), 0.8);
}
#[test]
fn test_content_hash_calculation() {
let content1 = "test content";
let content2 = "test content";
let content3 = "different content";
let hash1 = calculate_content_hash(content1);
let hash2 = calculate_content_hash(content2);
let hash3 = calculate_content_hash(content3);
assert_eq!(hash1, hash2); assert_ne!(hash1, hash3); assert_eq!(hash1.len(), 64); }
#[test]
fn test_cache_clear() -> Result<()> {
let temp_dir = TempDir::new()?;
let cache_dir = Utf8PathBuf::try_from(temp_dir.path().to_path_buf())?;
let mut cache = InsightCache::new(cache_dir)?;
cache.memory_cache.insert(
"test_key".to_string(),
CachedInsight {
content_hash: "hash123".to_string(),
file_path: "test.md".to_string(),
priority: Priority::Medium,
insights: vec!["test".to_string()],
phase: "requirements".to_string(),
cached_at: Utc::now(),
file_size: 100,
last_modified: Utc::now(),
},
);
cache.stats.hits = 5;
cache.stats.misses = 2;
assert!(!cache.memory_cache.is_empty());
assert!(cache.stats.hits > 0);
cache.clear()?;
assert!(cache.memory_cache.is_empty());
assert_eq!(cache.stats.hits, 0);
assert_eq!(cache.stats.misses, 0);
Ok(())
}
#[test]
fn test_insight_generation_tasks() {
let cache = InsightCache::new(Utf8PathBuf::from("/tmp")).unwrap();
let content = r"
# Implementation Tasks
## Milestone 1
- [ ] Task 1: Implement feature A
- [x] Task 2: Implement feature B
- [ ] Task 3: Write tests for feature A
- [x] Task 4: Write tests for feature B
## Milestone 2
- [ ] Task 5: Implement feature C
- [ ] Task 6: Test feature C
## Phase 1
Implementation phase for core features.
## Phase 2
Testing phase for all features.
";
let insights =
cache.generate_insights(content, Utf8Path::new("tasks.md"), "tasks", Priority::High);
assert!(insights.len() >= 10);
assert!(insights.len() <= 25);
let insights_text = insights.join(" ");
assert!(insights_text.contains("tasks") || insights_text.contains("Task"));
assert!(insights_text.contains("completed") || insights_text.contains("[x]"));
assert!(
insights_text.contains("Milestone")
|| insights_text.contains("milestone")
|| insights_text.contains("Phase")
|| insights_text.contains("phase")
);
}
#[test]
fn test_insight_generation_review() {
let cache = InsightCache::new(Utf8PathBuf::from("/tmp")).unwrap();
let content = r"
# Review Document
## Feedback
The implementation looks good overall, but there are some issues to address.
## Issues
1. Issue with error handling in module A
2. Problem with performance in module B
## Recommendations
- Recommend refactoring module A for better error handling
- Recommend optimizing module B for better performance
## FIXUP
The following fixups are needed:
- Fix error handling in module A
- Optimize performance in module B
";
let insights = cache.generate_insights(
content,
Utf8Path::new("review.md"),
"review",
Priority::High,
);
assert!(insights.len() >= 10);
assert!(insights.len() <= 25);
let insights_text = insights.join(" ");
assert!(
insights_text.contains("FIXUP")
|| insights_text.contains("fixup")
|| insights_text.contains("feedback")
|| insights_text.contains("Feedback")
|| insights_text.contains("issue")
|| insights_text.contains("Issue")
|| insights_text.contains("recommend")
|| insights_text.contains("Recommend")
);
}
#[test]
fn test_insight_generation_generic() {
let cache = InsightCache::new(Utf8PathBuf::from("/tmp")).unwrap();
let content = r#"
# Generic Document
## Section 1
This is some generic content with multiple paragraphs.
This is another paragraph.
## Section 2
- List item 1
- List item 2
* List item 3
[Link to something](https://example.com)
```rust
fn example() {
println!("Hello, world!");
}
```
```json
{
"key": "value"
}
```
"#;
let insights = cache.generate_insights(
content,
Utf8Path::new("generic.md"),
"unknown",
Priority::Medium,
);
assert!(insights.len() >= 10);
assert!(insights.len() <= 25);
let insights_text = insights.join(" ");
assert!(insights_text.contains("sections") || insights_text.contains("Section"));
assert!(insights_text.contains("code blocks") || insights_text.contains("code"));
assert!(insights_text.contains("list items") || insights_text.contains("List"));
}
#[test]
fn test_cache_statistics_logging() {
use crate::logging::Logger;
let temp_dir = TempDir::new().unwrap();
let cache_dir = Utf8PathBuf::try_from(temp_dir.path().to_path_buf()).unwrap();
let mut cache = InsightCache::new(cache_dir).unwrap();
cache.stats.hits = 7;
cache.stats.misses = 3;
cache.stats.invalidations = 1;
cache.stats.writes = 3;
let logger = Logger::new(true);
cache.log_stats(&logger);
assert_eq!(cache.stats().hit_ratio(), 0.7);
}
#[test]
fn test_memory_cache_hit() -> Result<()> {
let temp_dir = TempDir::new()?;
let cache_dir = Utf8PathBuf::try_from(temp_dir.path().to_path_buf())?;
let mut cache = InsightCache::new(cache_dir)?;
let test_file = temp_dir.path().join("test.md");
let content = "# Test\nMemory cache test.";
fs::write(&test_file, content)?;
let file_path = Utf8PathBuf::try_from(test_file)?;
let content_hash = calculate_content_hash(content);
let insights = vec![
"Insight 1".to_string(),
"Insight 2".to_string(),
"Insight 3".to_string(),
];
cache.store_insights(
&file_path,
content,
&content_hash,
"requirements",
Priority::High,
insights.clone(),
None,
)?;
let result1 = cache.get_insights(&file_path, &content_hash, "requirements", None)?;
assert!(result1.is_some());
assert_eq!(result1.unwrap(), insights);
assert_eq!(cache.stats().hits, 1);
assert_eq!(cache.stats().misses, 0);
let result2 = cache.get_insights(&file_path, &content_hash, "requirements", None)?;
assert!(result2.is_some());
assert_eq!(result2.unwrap(), insights);
assert_eq!(cache.stats().hits, 2);
assert_eq!(cache.stats().misses, 0);
Ok(())
}
#[test]
fn test_cache_key_uniqueness() {
let cache = InsightCache::new(Utf8PathBuf::from("/tmp")).unwrap();
let keys = vec![
cache.cache_key("hash1", "requirements"),
cache.cache_key("hash1", "design"),
cache.cache_key("hash1", "tasks"),
cache.cache_key("hash1", "review"),
cache.cache_key("hash2", "requirements"),
cache.cache_key("hash2", "design"),
];
for i in 0..keys.len() {
for j in (i + 1)..keys.len() {
assert_ne!(
keys[i], keys[j],
"Keys at indices {i} and {j} should be different"
);
}
}
for key in &keys {
assert!(key.contains('_'), "Key should contain underscore separator");
}
}
#[test]
fn test_insight_count_bounds() {
let cache = InsightCache::new(Utf8PathBuf::from("/tmp")).unwrap();
let minimal_content = "x";
let insights_min = cache.generate_insights(
minimal_content,
Utf8Path::new("minimal.md"),
"requirements",
Priority::Low,
);
assert!(
insights_min.len() >= 10,
"Should have at least 10 insights, got {}",
insights_min.len()
);
assert!(
insights_min.len() <= 25,
"Should have at most 25 insights, got {}",
insights_min.len()
);
let rich_content = r"
# Rich Document
## Section 1
Content here.
## Section 2
More content.
## Section 3
Even more content.
- List item 1
- List item 2
- List item 3
```rust
code here
```
[Link](url)
**User Story:** As a user, I want features.
WHEN something THEN something else SHALL happen.
";
let insights_rich = cache.generate_insights(
rich_content,
Utf8Path::new("rich.md"),
"requirements",
Priority::High,
);
assert!(
insights_rich.len() >= 10,
"Should have at least 10 insights, got {}",
insights_rich.len()
);
assert!(
insights_rich.len() <= 25,
"Should have at most 25 insights, got {}",
insights_rich.len()
);
}
#[test]
fn test_corrupted_cache_file_handling() -> Result<()> {
let temp_dir = TempDir::new()?;
let cache_dir = Utf8PathBuf::try_from(temp_dir.path().to_path_buf())?;
let mut cache = InsightCache::new(cache_dir)?;
let test_file = temp_dir.path().join("test.md");
let content = "# Test\nCorrupted cache test.";
fs::write(&test_file, content)?;
let file_path = Utf8PathBuf::try_from(test_file)?;
let content_hash = calculate_content_hash(content);
let key = cache.cache_key(&content_hash, "requirements");
let cache_file = cache.cache_file_path(&key);
fs::write(&cache_file, "{ invalid json }")?;
let result = cache.get_insights(&file_path, &content_hash, "requirements", None)?;
assert!(result.is_none());
assert_eq!(cache.stats().misses, 1);
assert!(!cache_file.exists());
Ok(())
}
#[test]
fn test_hash_mismatch_invalidation() -> Result<()> {
let temp_dir = TempDir::new()?;
let cache_dir = Utf8PathBuf::try_from(temp_dir.path().to_path_buf())?;
let mut cache = InsightCache::new(cache_dir)?;
let test_file = temp_dir.path().join("test.md");
let content = "# Test\nHash mismatch test.";
fs::write(&test_file, content)?;
let file_path = Utf8PathBuf::try_from(test_file.clone())?;
let content_hash1 = calculate_content_hash(content);
let insights = vec!["Test insight".to_string()];
cache.store_insights(
&file_path,
content,
&content_hash1,
"requirements",
Priority::Medium,
insights,
None,
)?;
thread::sleep(Duration::from_millis(10));
let new_content = "# Test\nDifferent content.";
fs::write(&test_file, new_content)?;
let result = cache.get_insights(&file_path, &content_hash1, "requirements", None)?;
assert!(result.is_none());
assert!(cache.stats().invalidations > 0);
Ok(())
}
}