use crate::cli_handlers::config_commands::{config_get, config_set};
use crate::error::Result;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::sync::atomic::{AtomicI64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static LAST_ANALYSIS_TIME: AtomicI64 = AtomicI64::new(0);
const DEFAULT_ANALYSIS_COOLDOWN_SECS: i64 = 300;
const MAX_ACTIVE_SUGGESTIONS: i64 = 20;
fn get_current_timestamp() -> Option<i64> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64)
}
fn should_trigger_analysis() -> bool {
let now = match get_current_timestamp() {
Some(ts) => ts,
None => {
tracing::warn!("System clock error, allowing analysis as fail-safe");
return true;
},
};
let last = LAST_ANALYSIS_TIME.load(Ordering::Acquire);
if now < last {
tracing::warn!(
"Clock skew detected: current={}, last={}, resetting analysis timer",
now,
last
);
LAST_ANALYSIS_TIME.store(now, Ordering::Release);
return true;
}
let cooldown = DEFAULT_ANALYSIS_COOLDOWN_SECS;
now - last >= cooldown
}
fn mark_analysis_started() {
if let Some(now) = get_current_timestamp() {
LAST_ANALYSIS_TIME.store(now, Ordering::Release);
} else {
tracing::warn!("System clock error, cannot update analysis timestamp");
}
}
#[derive(Debug, Clone)]
pub struct LlmConfig {
pub endpoint: String,
pub api_key: String,
pub model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
}
#[derive(Debug, Deserialize)]
struct ChatResponse {
choices: Vec<ChatChoice>,
}
#[derive(Debug, Deserialize)]
struct ChatChoice {
message: ChatResponseMessage,
}
#[derive(Debug, Deserialize)]
struct ChatResponseMessage {
content: String,
}
impl LlmConfig {
pub async fn resolve(pool: &SqlitePool) -> Result<Option<Self>> {
let endpoint = Self::resolve_field(pool, "IE_LLM_ENDPOINT", "llm.endpoint").await?;
let api_key = Self::resolve_field(pool, "IE_LLM_API_KEY", "llm.api_key").await?;
let model = Self::resolve_field(pool, "IE_LLM_MODEL", "llm.model").await?;
match (endpoint, api_key, model) {
(Some(endpoint), Some(api_key), Some(model)) => Ok(Some(Self {
endpoint,
api_key,
model,
})),
_ => Ok(None),
}
}
async fn resolve_field(
pool: &SqlitePool,
env_var: &str,
config_key: &str,
) -> Result<Option<String>> {
if let Ok(val) = std::env::var(env_var) {
if !val.is_empty() {
return Ok(Some(val));
}
}
config_get(pool, config_key).await
}
pub async fn save(&self, pool: &SqlitePool) -> Result<()> {
config_set(pool, "llm.endpoint", &self.endpoint).await?;
config_set(pool, "llm.api_key", &self.api_key).await?;
config_set(pool, "llm.model", &self.model).await?;
Ok(())
}
}
pub struct LlmClient {
config: LlmConfig,
client: reqwest::Client,
}
impl LlmClient {
pub async fn from_pool(pool: &SqlitePool) -> Result<Self> {
let config = LlmConfig::resolve(pool).await?.ok_or_else(|| {
crate::error::IntentError::InvalidInput(
"LLM not configured. Set llm.endpoint, llm.api_key, and llm.model via 'ie config set' or environment variables (IE_LLM_ENDPOINT, IE_LLM_API_KEY, IE_LLM_MODEL).".to_string(),
)
})?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| crate::error::IntentError::OtherError(e.into()))?;
Ok(Self { config, client })
}
pub async fn is_configured(pool: &SqlitePool) -> bool {
matches!(LlmConfig::resolve(pool).await, Ok(Some(_)))
}
pub async fn chat(&self, prompt: &str) -> Result<String> {
let messages = vec![ChatMessage {
role: "user".to_string(),
content: prompt.to_string(),
}];
self.chat_with_messages(messages).await
}
pub async fn chat_with_messages(&self, messages: Vec<ChatMessage>) -> Result<String> {
let request = ChatRequest {
model: self.config.model.clone(),
messages,
};
let response = self
.client
.post(&self.config.endpoint)
.header("Authorization", format!("Bearer {}", self.config.api_key))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.map_err(|e| crate::error::IntentError::OtherError(e.into()))?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "(no body)".to_string());
return Err(crate::error::IntentError::OtherError(anyhow::anyhow!(
"LLM API error (HTTP {}): {}",
status,
body
)));
}
let chat_response: ChatResponse = response
.json()
.await
.map_err(|e| crate::error::IntentError::OtherError(e.into()))?;
chat_response
.choices
.into_iter()
.next()
.map(|c| c.message.content)
.ok_or_else(|| {
crate::error::IntentError::OtherError(anyhow::anyhow!("LLM returned empty choices"))
})
}
pub async fn synthesize_task_description(
&self,
task_name: &str,
original_spec: Option<&str>,
events: &[crate::db::models::Event],
) -> Result<String> {
let events_text = if events.is_empty() {
"No events recorded.".to_string()
} else {
events
.iter()
.map(|e| {
format!(
"[{}] {} - {}",
e.log_type,
e.timestamp.format("%Y-%m-%d %H:%M"),
e.discussion_data
)
})
.collect::<Vec<_>>()
.join("\n")
};
let original_spec_text = original_spec.unwrap_or("(No original description)");
let is_cjk = task_name.chars().any(|c| {
matches!(c,
'\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{AC00}'..='\u{D7AF}' )
});
let language_instruction = if is_cjk {
"Respond in Chinese (中文)."
} else {
"Respond in English."
};
let prompt = format!(
r#"You are summarizing a completed task based on its execution history.
Task: {}
Original description: {}
Events (chronological):
{}
Synthesize a clear, structured description capturing:
1. Goal (what was the objective?)
2. Approach (how was it accomplished?)
3. Key Decisions (what choices were made and why?)
4. Outcome (what was delivered?)
Use markdown format with ## headers. Be concise but preserve critical context.
Output ONLY the markdown summary, no preamble or explanation.
IMPORTANT: {}"#,
task_name, original_spec_text, events_text, language_instruction
);
self.chat(&prompt).await
}
}
pub async fn synthesize_task_description(
pool: &SqlitePool,
task_name: &str,
original_spec: Option<&str>,
events: &[crate::db::models::Event],
) -> Result<Option<String>> {
if !LlmClient::is_configured(pool).await {
return Ok(None);
}
let client = LlmClient::from_pool(pool).await?;
let synthesis = client
.synthesize_task_description(task_name, original_spec, events)
.await?;
Ok(Some(synthesis))
}
pub fn analyze_task_structure_background(pool: SqlitePool) {
if !should_trigger_analysis() {
tracing::debug!("Analysis cooldown active, skipping background analysis");
return;
}
mark_analysis_started();
tokio::spawn(async move {
if let Err(e) = analyze_and_store_suggestions(&pool).await {
let error_msg = format!(
"## Analysis Error\n\n\
Background task structure analysis failed: {}\n\n\
This may indicate:\n\
- LLM API endpoint is unreachable\n\
- API quota exceeded\n\
- Network connectivity issues\n\n\
Check logs for details: `ie log`",
e
);
match store_suggestion(&pool, "error", &error_msg).await {
Ok(_) => {
tracing::warn!("Background task analysis failed: {}", e);
},
Err(store_err) => {
tracing::error!("Failed to store error suggestion: {}", store_err);
eprintln!(
"\n⚠️ Background analysis failed AND couldn't store error.\n\
Analysis error: {}\n\
Storage error: {}\n\
This may indicate database issues.",
e, store_err
);
},
}
}
});
}
async fn analyze_and_store_suggestions(pool: &SqlitePool) -> Result<()> {
if !LlmClient::is_configured(pool).await {
return Ok(()); }
let tasks: Vec<crate::db::models::Task> = sqlx::query_as(
"SELECT id, parent_id, name, spec, status, complexity, priority, \
first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata \
FROM tasks ORDER BY id",
)
.fetch_all(pool)
.await?;
if tasks.len() < 5 {
return Ok(());
}
let analysis = perform_structure_analysis(pool, &tasks).await?;
if !analysis.contains("no reorganization needed") && !analysis.contains("looks good") {
store_suggestion(pool, "task_structure", &analysis).await?;
}
Ok(())
}
async fn perform_structure_analysis(
pool: &SqlitePool,
tasks: &[crate::db::models::Task],
) -> Result<String> {
let task_summary = tasks
.iter()
.map(|t| {
format!(
"#{} {} [{}] (parent: {})",
t.id,
t.name,
t.status,
t.parent_id.map_or("none".to_string(), |p| p.to_string())
)
})
.collect::<Vec<_>>()
.join("\n");
let is_cjk = tasks.iter().any(|t| {
t.name.chars().any(|c| {
matches!(c,
'\u{4E00}'..='\u{9FFF}' |
'\u{3400}'..='\u{4DBF}' |
'\u{3040}'..='\u{309F}' |
'\u{30A0}'..='\u{30FF}' |
'\u{AC00}'..='\u{D7AF}'
)
})
});
let language_instruction = if is_cjk {
"Respond in Chinese (中文)."
} else {
"Respond in English."
};
let prompt = format!(
r#"You are analyzing a task hierarchy for structural issues.
Current task tree:
{}
Identify tasks that should be reorganized:
1. Semantically related tasks that should be grouped under a common parent
2. Root tasks that could be subtasks of existing tasks
3. Tasks with similar names or themes that should share a parent
For each suggestion:
- Explain WHY the reorganization makes sense
- Provide the EXACT command to execute
- Only suggest if there's clear semantic relationship
Output format:
## Suggestion 1: [Brief description]
**Reason**: [Why this makes sense]
**Command**: `ie task update <id> --parent <parent_id>`
If no reorganization needed, respond with: "Task structure looks good, no reorganization needed."
IMPORTANT: {}"#,
task_summary, language_instruction
);
let client = LlmClient::from_pool(pool).await?;
let analysis = client.chat(&prompt).await?;
Ok(analysis)
}
async fn store_suggestion(pool: &SqlitePool, suggestion_type: &str, content: &str) -> Result<()> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM suggestions WHERE dismissed = 0")
.fetch_one(pool)
.await?;
if count >= MAX_ACTIVE_SUGGESTIONS {
let dismissed = sqlx::query(
"UPDATE suggestions SET dismissed = 1
WHERE id IN (
SELECT id FROM suggestions
WHERE dismissed = 0
ORDER BY created_at ASC
LIMIT 1
)",
)
.execute(pool)
.await?;
if dismissed.rows_affected() > 0 {
tracing::info!(
"Auto-dismissed oldest suggestion (limit: {})",
MAX_ACTIVE_SUGGESTIONS
);
}
}
sqlx::query("INSERT INTO suggestions (type, content) VALUES (?, ?)")
.bind(suggestion_type)
.bind(content)
.execute(pool)
.await?;
tracing::info!("Stored {} suggestion in database", suggestion_type);
Ok(())
}
pub async fn get_active_suggestions(
pool: &SqlitePool,
) -> Result<Vec<crate::db::models::Suggestion>> {
let suggestions = sqlx::query_as::<_, crate::db::models::Suggestion>(
"SELECT id, type, content, created_at, dismissed \
FROM suggestions \
WHERE dismissed = 0 \
ORDER BY created_at DESC",
)
.fetch_all(pool)
.await?;
Ok(suggestions)
}
pub async fn dismiss_suggestion(pool: &SqlitePool, id: i64) -> Result<()> {
sqlx::query("UPDATE suggestions SET dismissed = 1 WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn dismiss_all_suggestions(pool: &SqlitePool) -> Result<usize> {
let result = sqlx::query("UPDATE suggestions SET dismissed = 1 WHERE dismissed = 0")
.execute(pool)
.await?;
Ok(result.rows_affected() as usize)
}
pub async fn clear_dismissed_suggestions(pool: &SqlitePool) -> Result<usize> {
let result = sqlx::query("DELETE FROM suggestions WHERE dismissed = 1")
.execute(pool)
.await?;
Ok(result.rows_affected() as usize)
}
pub async fn display_suggestions(pool: &SqlitePool) -> Result<()> {
let suggestions = get_active_suggestions(pool).await?;
if suggestions.is_empty() {
return Ok(());
}
let (errors, others): (Vec<_>, Vec<_>) = suggestions
.iter()
.partition(|s| s.suggestion_type == "error");
if !errors.is_empty() {
eprintln!("\n⚠️ Background Analysis Errors:");
for error in &errors {
eprintln!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
eprintln!("{}", error.content);
eprintln!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
}
eprintln!("\nTo dismiss: ie suggestions dismiss {}", errors[0].id);
eprintln!("To dismiss all: ie suggestions dismiss --all");
}
if !others.is_empty() {
eprintln!("\n💡 Suggestions:");
for suggestion in &others {
eprintln!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
eprintln!("{}", suggestion.content);
eprintln!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
}
eprintln!("\nTo dismiss: ie suggestions dismiss {}", others[0].id);
eprintln!("To list all: ie suggestions list");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::test_helpers::TestContext;
#[tokio::test]
async fn test_llm_config_resolve_none_when_unconfigured() {
let ctx = TestContext::new().await;
let config = LlmConfig::resolve(ctx.pool()).await.unwrap();
assert!(config.is_none());
}
#[tokio::test]
async fn test_llm_config_resolve_partial_returns_none() {
let ctx = TestContext::new().await;
config_set(ctx.pool(), "llm.endpoint", "http://localhost:8080")
.await
.unwrap();
config_set(ctx.pool(), "llm.model", "gpt-4").await.unwrap();
let config = LlmConfig::resolve(ctx.pool()).await.unwrap();
assert!(config.is_none());
}
#[tokio::test]
async fn test_llm_config_resolve_full() {
let ctx = TestContext::new().await;
config_set(
ctx.pool(),
"llm.endpoint",
"http://localhost:8080/v1/chat/completions",
)
.await
.unwrap();
config_set(ctx.pool(), "llm.api_key", "sk-test123")
.await
.unwrap();
config_set(ctx.pool(), "llm.model", "gpt-4").await.unwrap();
let config = LlmConfig::resolve(ctx.pool()).await.unwrap();
assert!(config.is_some());
let config = config.unwrap();
assert_eq!(config.endpoint, "http://localhost:8080/v1/chat/completions");
assert_eq!(config.api_key, "sk-test123");
assert_eq!(config.model, "gpt-4");
}
#[tokio::test]
async fn test_llm_config_save_and_resolve() {
let ctx = TestContext::new().await;
let config = LlmConfig {
endpoint: "http://example.com/v1/chat/completions".to_string(),
api_key: "sk-saved".to_string(),
model: "claude-3".to_string(),
};
config.save(ctx.pool()).await.unwrap();
let resolved = LlmConfig::resolve(ctx.pool()).await.unwrap().unwrap();
assert_eq!(resolved.endpoint, "http://example.com/v1/chat/completions");
assert_eq!(resolved.api_key, "sk-saved");
assert_eq!(resolved.model, "claude-3");
}
#[tokio::test]
async fn test_is_configured_false() {
let ctx = TestContext::new().await;
assert!(!LlmClient::is_configured(ctx.pool()).await);
}
#[tokio::test]
async fn test_is_configured_true() {
let ctx = TestContext::new().await;
config_set(ctx.pool(), "llm.endpoint", "http://localhost:8080")
.await
.unwrap();
config_set(ctx.pool(), "llm.api_key", "sk-test")
.await
.unwrap();
config_set(ctx.pool(), "llm.model", "gpt-4").await.unwrap();
assert!(LlmClient::is_configured(ctx.pool()).await);
}
#[tokio::test]
async fn test_from_pool_errors_when_unconfigured() {
let ctx = TestContext::new().await;
let result = LlmClient::from_pool(ctx.pool()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_chat_message_serialization() {
let msg = ChatMessage {
role: "user".to_string(),
content: "Hello".to_string(),
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"role\":\"user\""));
assert!(json.contains("\"content\":\"Hello\""));
}
#[tokio::test]
async fn test_synthesize_task_description_when_unconfigured() {
let ctx = TestContext::new().await;
use chrono::Utc;
let event = crate::db::models::Event {
id: 1,
task_id: 1,
log_type: "decision".to_string(),
discussion_data: "Test decision".to_string(),
timestamp: Utc::now(),
};
let result =
synthesize_task_description(ctx.pool(), "Test Task", Some("Original spec"), &[event])
.await
.unwrap();
assert!(
result.is_none(),
"Should return None when LLM not configured"
);
}
#[tokio::test]
async fn test_synthesize_prompt_includes_task_info() {
use chrono::Utc;
let events = [
crate::db::models::Event {
id: 1,
task_id: 1,
log_type: "decision".to_string(),
discussion_data: "Chose approach A".to_string(),
timestamp: Utc::now(),
},
crate::db::models::Event {
id: 2,
task_id: 1,
log_type: "milestone".to_string(),
discussion_data: "Completed phase 1".to_string(),
timestamp: Utc::now(),
},
];
let events_text: String = events
.iter()
.map(|e| {
format!(
"[{}] {} - {}",
e.log_type,
e.timestamp.format("%Y-%m-%d %H:%M"),
e.discussion_data
)
})
.collect::<Vec<_>>()
.join("\n");
assert!(events_text.contains("decision"));
assert!(events_text.contains("Chose approach A"));
assert!(events_text.contains("milestone"));
assert!(events_text.contains("Completed phase 1"));
}
#[tokio::test]
async fn test_synthesize_with_empty_events() {
let events: Vec<crate::db::models::Event> = vec![];
assert_eq!(events.len(), 0);
}
#[tokio::test]
async fn test_synthesize_with_no_original_spec() {
use chrono::Utc;
let original_spec: Option<&str> = None;
let events = [crate::db::models::Event {
id: 1,
task_id: 1,
log_type: "note".to_string(),
discussion_data: "Some work done".to_string(),
timestamp: Utc::now(),
}];
assert!(original_spec.is_none());
assert_eq!(events.len(), 1);
}
#[test]
fn test_language_detection() {
let chinese_task = "实现用户认证";
let english_task = "Implement authentication";
let japanese_task = "認証を実装する";
let korean_task = "인증 구현";
let is_cjk = chinese_task.chars().any(|c| {
matches!(c,
'\u{4E00}'..='\u{9FFF}' |
'\u{3400}'..='\u{4DBF}' |
'\u{3040}'..='\u{309F}' |
'\u{30A0}'..='\u{30FF}' |
'\u{AC00}'..='\u{D7AF}'
)
});
assert!(is_cjk, "Should detect Chinese characters");
let is_cjk = english_task.chars().any(|c| {
matches!(c,
'\u{4E00}'..='\u{9FFF}' |
'\u{3400}'..='\u{4DBF}' |
'\u{3040}'..='\u{309F}' |
'\u{30A0}'..='\u{30FF}' |
'\u{AC00}'..='\u{D7AF}'
)
});
assert!(!is_cjk, "Should not detect CJK in English text");
let is_cjk = japanese_task.chars().any(|c| {
matches!(c,
'\u{4E00}'..='\u{9FFF}' |
'\u{3400}'..='\u{4DBF}' |
'\u{3040}'..='\u{309F}' |
'\u{30A0}'..='\u{30FF}' |
'\u{AC00}'..='\u{D7AF}'
)
});
assert!(is_cjk, "Should detect Japanese characters");
let is_cjk = korean_task.chars().any(|c| {
matches!(c,
'\u{4E00}'..='\u{9FFF}' |
'\u{3400}'..='\u{4DBF}' |
'\u{3040}'..='\u{309F}' |
'\u{30A0}'..='\u{30FF}' |
'\u{AC00}'..='\u{D7AF}'
)
});
assert!(is_cjk, "Should detect Korean characters");
}
#[tokio::test]
async fn test_store_and_retrieve_suggestions() {
let ctx = TestContext::new().await;
store_suggestion(
ctx.pool(),
"task_structure",
"## Suggestion\nReorganize task #5 under task #3",
)
.await
.unwrap();
let suggestions = get_active_suggestions(ctx.pool()).await.unwrap();
assert_eq!(suggestions.len(), 1);
assert_eq!(suggestions[0].suggestion_type, "task_structure");
assert!(suggestions[0].content.contains("Reorganize"));
assert!(!suggestions[0].dismissed);
}
#[tokio::test]
async fn test_dismiss_suggestion() {
let ctx = TestContext::new().await;
store_suggestion(ctx.pool(), "task_structure", "Test suggestion")
.await
.unwrap();
let suggestions = get_active_suggestions(ctx.pool()).await.unwrap();
assert_eq!(suggestions.len(), 1);
let suggestion_id = suggestions[0].id;
dismiss_suggestion(ctx.pool(), suggestion_id).await.unwrap();
let active = get_active_suggestions(ctx.pool()).await.unwrap();
assert_eq!(active.len(), 0);
}
#[tokio::test]
async fn test_multiple_suggestions() {
let ctx = TestContext::new().await;
store_suggestion(ctx.pool(), "task_structure", "Suggestion 1")
.await
.unwrap();
store_suggestion(ctx.pool(), "task_structure", "Suggestion 2")
.await
.unwrap();
store_suggestion(ctx.pool(), "event_synthesis", "Suggestion 3")
.await
.unwrap();
let suggestions = get_active_suggestions(ctx.pool()).await.unwrap();
assert_eq!(suggestions.len(), 3);
assert!(suggestions[0].content.contains("Suggestion 3"));
}
#[tokio::test]
async fn test_dismiss_all_suggestions() {
let ctx = TestContext::new().await;
store_suggestion(ctx.pool(), "task_structure", "Suggestion 1")
.await
.unwrap();
store_suggestion(ctx.pool(), "task_structure", "Suggestion 2")
.await
.unwrap();
store_suggestion(ctx.pool(), "error", "Error message")
.await
.unwrap();
let active = get_active_suggestions(ctx.pool()).await.unwrap();
assert_eq!(active.len(), 3);
let count = dismiss_all_suggestions(ctx.pool()).await.unwrap();
assert_eq!(count, 3);
let remaining = get_active_suggestions(ctx.pool()).await.unwrap();
assert_eq!(remaining.len(), 0);
}
#[tokio::test]
async fn test_clear_dismissed_suggestions() {
let ctx = TestContext::new().await;
store_suggestion(ctx.pool(), "task_structure", "Suggestion 1")
.await
.unwrap();
store_suggestion(ctx.pool(), "task_structure", "Suggestion 2")
.await
.unwrap();
let suggestions = get_active_suggestions(ctx.pool()).await.unwrap();
dismiss_suggestion(ctx.pool(), suggestions[0].id)
.await
.unwrap();
dismiss_suggestion(ctx.pool(), suggestions[1].id)
.await
.unwrap();
let count = clear_dismissed_suggestions(ctx.pool()).await.unwrap();
assert_eq!(count, 2);
let all: Vec<crate::db::models::Suggestion> =
sqlx::query_as("SELECT id, type, content, created_at, dismissed FROM suggestions")
.fetch_all(ctx.pool())
.await
.unwrap();
assert_eq!(all.len(), 0);
}
#[tokio::test]
async fn test_error_suggestion_storage() {
let ctx = TestContext::new().await;
let error_msg = "LLM API failed: connection timeout";
store_suggestion(ctx.pool(), "error", error_msg)
.await
.unwrap();
let suggestions = get_active_suggestions(ctx.pool()).await.unwrap();
assert_eq!(suggestions.len(), 1);
assert_eq!(suggestions[0].suggestion_type, "error");
assert!(suggestions[0].content.contains("timeout"));
}
#[test]
fn test_rate_limiting_cooldown() {
let original = LAST_ANALYSIS_TIME.load(Ordering::SeqCst);
LAST_ANALYSIS_TIME.store(0, Ordering::SeqCst);
assert!(should_trigger_analysis());
mark_analysis_started();
assert!(!should_trigger_analysis());
if let Some(now) = get_current_timestamp() {
let past = now - DEFAULT_ANALYSIS_COOLDOWN_SECS - 1;
LAST_ANALYSIS_TIME.store(past, Ordering::SeqCst);
assert!(should_trigger_analysis());
}
LAST_ANALYSIS_TIME.store(original, Ordering::SeqCst);
}
#[test]
fn test_rate_limiting_clock_skew() {
let original = LAST_ANALYSIS_TIME.load(Ordering::SeqCst);
if let Some(now) = get_current_timestamp() {
let future = now + 1000;
LAST_ANALYSIS_TIME.store(future, Ordering::SeqCst);
assert!(should_trigger_analysis());
let reset_time = LAST_ANALYSIS_TIME.load(Ordering::SeqCst);
assert!(
reset_time <= now,
"Timer should be reset to current or earlier"
);
}
LAST_ANALYSIS_TIME.store(original, Ordering::SeqCst);
}
#[test]
fn test_get_current_timestamp_returns_valid() {
let ts = get_current_timestamp();
assert!(ts.is_some());
if let Some(timestamp) = ts {
assert!(timestamp > 1577836800); }
}
#[tokio::test]
async fn test_max_active_suggestions_limit() {
let ctx = TestContext::new().await;
for i in 0..MAX_ACTIVE_SUGGESTIONS {
store_suggestion(ctx.pool(), "task_structure", &format!("Suggestion {}", i))
.await
.unwrap();
}
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM suggestions WHERE dismissed = 0")
.fetch_one(ctx.pool())
.await
.unwrap();
assert_eq!(count, MAX_ACTIVE_SUGGESTIONS);
store_suggestion(ctx.pool(), "task_structure", "New suggestion")
.await
.unwrap();
let count_after: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM suggestions WHERE dismissed = 0")
.fetch_one(ctx.pool())
.await
.unwrap();
assert_eq!(count_after, MAX_ACTIVE_SUGGESTIONS);
let suggestions = get_active_suggestions(ctx.pool()).await.unwrap();
assert!(suggestions[0].content.contains("New suggestion"));
let dismissed_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM suggestions WHERE dismissed = 1")
.fetch_one(ctx.pool())
.await
.unwrap();
assert_eq!(dismissed_count, 1);
}
#[tokio::test]
async fn test_error_type_suggestions() {
let ctx = TestContext::new().await;
store_suggestion(ctx.pool(), "error", "## Analysis Error\n\nLLM API failed")
.await
.unwrap();
store_suggestion(ctx.pool(), "task_structure", "Reorganize task #5")
.await
.unwrap();
let suggestions = get_active_suggestions(ctx.pool()).await.unwrap();
assert_eq!(suggestions.len(), 2);
let errors: Vec<_> = suggestions
.iter()
.filter(|s| s.suggestion_type == "error")
.collect();
assert_eq!(errors.len(), 1);
assert!(errors[0].content.contains("Analysis Error"));
}
#[tokio::test]
async fn test_concurrent_cooldown_check() {
use std::sync::Arc;
use tokio::sync::Barrier;
let original = LAST_ANALYSIS_TIME.load(Ordering::SeqCst);
LAST_ANALYSIS_TIME.store(0, Ordering::SeqCst);
let barrier = Arc::new(Barrier::new(5));
let mut handles = vec![];
for _ in 0..5 {
let b = Arc::clone(&barrier);
let handle = tokio::spawn(async move {
b.wait().await;
should_trigger_analysis()
});
handles.push(handle);
}
let mut results = vec![];
for handle in handles {
results.push(handle.await.unwrap());
}
let success_count = results.iter().filter(|&&r| r).count();
assert!(
success_count > 0,
"At least one concurrent check should succeed"
);
LAST_ANALYSIS_TIME.store(original, Ordering::SeqCst);
}
}