diff --git a/Cargo.toml b/Cargo.toml index 236e4e0a..d73def57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,7 @@ term_size = "0.3" # Vulnerability checking dependencies rustsec = "0.30" reqwest = { version = "0.12", features = ["json", "blocking"] } -tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "sync", "process", "io-util"] } +tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "sync", "process", "io-util", "signal"] } textwrap = "0.16" tempfile = "3" dirs = "6" diff --git a/src/agent/commands.rs b/src/agent/commands.rs index 76529080..ca49d6c8 100644 --- a/src/agent/commands.rs +++ b/src/agent/commands.rs @@ -78,6 +78,18 @@ pub const SLASH_COMMANDS: &[SlashCommand] = &[ description: "Show incomplete plans and continue", auto_execute: true, }, + SlashCommand { + name: "resume", + alias: Some("s"), + description: "Browse and resume previous sessions", + auto_execute: true, + }, + SlashCommand { + name: "sessions", + alias: Some("ls"), + description: "List available sessions for this project", + auto_execute: true, + }, SlashCommand { name: "exit", alias: Some("q"), diff --git a/src/agent/compact/strategy.rs b/src/agent/compact/strategy.rs index 4aa621cd..ccd843cb 100644 --- a/src/agent/compact/strategy.rs +++ b/src/agent/compact/strategy.rs @@ -169,8 +169,8 @@ impl CompactionStrategy { // We're evicting a tool call - need to also evict its result // Find the tool result with matching ID if let Some(tool_id) = &last_evicted.tool_id { - for i in end..messages.len().min(end + 5) { - if messages[i].is_tool_result && messages[i].tool_id.as_ref() == Some(tool_id) { + for (i, msg) in messages.iter().enumerate().skip(end).take(5) { + if msg.is_tool_result && msg.tool_id.as_ref() == Some(tool_id) { // Found matching result - extend eviction to include it end = i + 1; break; diff --git a/src/agent/mod.rs b/src/agent/mod.rs index d94bf4b3..b054ee9f 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -34,6 +34,7 @@ pub mod commands; pub mod compact; pub mod history; pub mod ide; +pub mod persistence; pub mod prompts; pub mod session; pub mod tools; @@ -117,7 +118,7 @@ fn get_system_prompt(project_path: &Path, query: Option<&str>, plan_mode: PlanMo } // Then check if it's DevOps generation (Docker, Terraform, Helm) if prompts::is_generation_query(q) { - return prompts::get_devops_prompt(project_path); + return prompts::get_devops_prompt(project_path, Some(q)); } } // Default to analysis prompt @@ -134,6 +135,10 @@ pub async fn run_interactive( let mut session = ChatSession::new(project_path, provider, model); + // Terminal layout for split screen is disabled for now - see notes below + // let terminal_layout = ui::TerminalLayout::new(); + // let layout_state = terminal_layout.state(); + // Initialize conversation history with compaction support let mut conversation_history = ConversationHistory::new(); @@ -176,6 +181,19 @@ pub async fn run_interactive( session.print_banner(); + // NOTE: Terminal layout with ANSI scroll regions is disabled for now. + // The scroll region approach conflicts with the existing input/output flow. + // TODO: Implement proper scroll region support that integrates with the input handler. + // For now, we rely on the pause/resume mechanism in progress indicator. + // + // if let Err(e) = terminal_layout.init() { + // eprintln!( + // "{}", + // format!("Note: Terminal layout initialization failed: {}. Using fallback mode.", e) + // .dimmed() + // ); + // } + // Raw Rig messages for multi-turn - preserves Reasoning blocks for thinking // Our ConversationHistory only stores text summaries, but rig needs full Message structure let mut raw_chat_history: Vec = Vec::new(); @@ -185,6 +203,9 @@ pub async fn run_interactive( // Auto-accept mode for plan execution (skips write confirmations) let mut auto_accept_writes = false; + // Initialize session recorder for conversation persistence + let mut session_recorder = persistence::SessionRecorder::new(project_path); + loop { // Show conversation status if we have history if !conversation_history.is_empty() { @@ -323,6 +344,12 @@ pub async fn run_interactive( // Create hook for Claude Code style tool display let hook = ToolDisplayHook::new(); + // Create progress indicator for visual feedback during generation + let progress = ui::GenerationIndicator::new(); + // Layout connection disabled - using inline progress mode + // progress.state().set_layout(layout_state.clone()); + hook.set_progress_state(progress.state()).await; + let project_path_buf = session.project_path.clone(); // Select prompt based on query type (analysis vs generation) and plan mode let preamble = get_system_prompt( @@ -336,7 +363,24 @@ pub async fn run_interactive( // Note: using raw_chat_history directly which preserves Reasoning blocks // This is needed for extended thinking to work with multi-turn conversations - let response = match session.provider { + // Get progress state for interrupt detection + let progress_state = progress.state(); + + // Use tokio::select! to race the API call against Ctrl+C + // This allows immediate cancellation, not just between tool calls + let mut user_interrupted = false; + + // API call with Ctrl+C interrupt support + let response = tokio::select! { + biased; // Check ctrl_c first for faster response + + _ = tokio::signal::ctrl_c() => { + user_interrupted = true; + Err::("User cancelled".to_string()) + } + + result = async { + match session.provider { ProviderType::OpenAI => { let client = openai::Client::from_env(); // For GPT-5.x reasoning models, enable reasoning with summary output @@ -368,7 +412,8 @@ pub async fn run_interactive( .tool(TerraformValidateTool::new(project_path_buf.clone())) .tool(TerraformInstallTool::new()) .tool(ReadFileTool::new(project_path_buf.clone())) - .tool(ListDirectoryTool::new(project_path_buf.clone())); + .tool(ListDirectoryTool::new(project_path_buf.clone())) + .tool(WebFetchTool::new()); // Add tools based on mode if is_planning { @@ -446,7 +491,8 @@ pub async fn run_interactive( .tool(TerraformValidateTool::new(project_path_buf.clone())) .tool(TerraformInstallTool::new()) .tool(ReadFileTool::new(project_path_buf.clone())) - .tool(ListDirectoryTool::new(project_path_buf.clone())); + .tool(ListDirectoryTool::new(project_path_buf.clone())) + .tool(WebFetchTool::new()); // Add tools based on mode if is_planning { @@ -528,7 +574,8 @@ pub async fn run_interactive( .tool(TerraformValidateTool::new(project_path_buf.clone())) .tool(TerraformInstallTool::new()) .tool(ReadFileTool::new(project_path_buf.clone())) - .tool(ListDirectoryTool::new(project_path_buf.clone())); + .tool(ListDirectoryTool::new(project_path_buf.clone())) + .tool(WebFetchTool::new()); // Add tools based on mode if is_planning { @@ -579,9 +626,17 @@ pub async fn run_interactive( .with_hook(hook.clone()) .multi_turn(50) .await - } + } + }.map_err(|e| e.to_string()) + } => result }; + // Stop the progress indicator before handling the response + progress.stop().await; + + // Suppress unused variable warnings + let _ = (&progress_state, user_interrupted); + match response { Ok(text) => { // Show final response @@ -663,6 +718,16 @@ pub async fn run_interactive( .history .push(("assistant".to_string(), text.clone())); + // Record to persistent session storage + session_recorder.record_user_message(&input); + session_recorder.record_assistant_message(&text, Some(&tool_calls)); + if let Err(e) = session_recorder.save() { + eprintln!( + "{}", + format!(" Warning: Failed to save session: {}", e).dimmed() + ); + } + // Check if plan_create was called - show interactive menu if let Some(plan_info) = find_plan_create_call(&tool_calls) { println!(); // Space before menu @@ -714,6 +779,32 @@ pub async fn run_interactive( println!(); + // Check if this was a user-initiated cancellation (Ctrl+C) + if err_str.contains("cancelled") || err_str.contains("Cancelled") { + // Extract any completed work before cancellation + let completed_tools = extract_tool_calls_from_hook(&hook).await; + let tool_count = completed_tools.len(); + + eprintln!("{}", "⚠ Generation interrupted.".yellow()); + if tool_count > 0 { + eprintln!( + "{}", + format!(" {} tool calls completed before interrupt.", tool_count) + .dimmed() + ); + // Add partial progress to history + conversation_history.add_turn( + current_input.clone(), + format!("[Interrupted after {} tool calls]", tool_count), + completed_tools, + ); + } + eprintln!("{}", " Type your next message to continue.".dimmed()); + + // Don't retry, don't mark as succeeded - just break to return to prompt + break; + } + // Check if this is a max depth error - handle as checkpoint if err_str.contains("MaxDepth") || err_str.contains("max_depth") @@ -1067,9 +1158,21 @@ pub async fn run_interactive( println!(); } + // Clean up terminal layout before exiting (disabled - layout not initialized) + // if let Err(e) = terminal_layout.cleanup() { + // eprintln!( + // "{}", + // format!("Warning: Terminal cleanup failed: {}", e).dimmed() + // ); + // } + Ok(()) } +// NOTE: wait_for_interrupt function removed - ESC interrupt feature disabled +// due to terminal corruption issues with spawn_blocking raw mode handling. +// TODO: Re-implement using tool hook callbacks for cleaner interruption. + /// Extract tool call records from the hook state for history tracking async fn extract_tool_calls_from_hook(hook: &ToolDisplayHook) -> Vec { let state = hook.state(); @@ -1422,7 +1525,8 @@ pub async fn run_query( .tool(TerraformValidateTool::new(project_path_buf.clone())) .tool(TerraformInstallTool::new()) .tool(ReadFileTool::new(project_path_buf.clone())) - .tool(ListDirectoryTool::new(project_path_buf.clone())); + .tool(ListDirectoryTool::new(project_path_buf.clone())) + .tool(WebFetchTool::new()); // Add generation tools if this is a generation query if is_generation { @@ -1467,7 +1571,8 @@ pub async fn run_query( .tool(TerraformValidateTool::new(project_path_buf.clone())) .tool(TerraformInstallTool::new()) .tool(ReadFileTool::new(project_path_buf.clone())) - .tool(ListDirectoryTool::new(project_path_buf.clone())); + .tool(ListDirectoryTool::new(project_path_buf.clone())) + .tool(WebFetchTool::new()); // Add generation tools if this is a generation query if is_generation { @@ -1515,7 +1620,8 @@ pub async fn run_query( .tool(TerraformValidateTool::new(project_path_buf.clone())) .tool(TerraformInstallTool::new()) .tool(ReadFileTool::new(project_path_buf.clone())) - .tool(ListDirectoryTool::new(project_path_buf.clone())); + .tool(ListDirectoryTool::new(project_path_buf.clone())) + .tool(WebFetchTool::new()); // Add generation tools if this is a generation query if is_generation { diff --git a/src/agent/persistence.rs b/src/agent/persistence.rs new file mode 100644 index 00000000..531e038a --- /dev/null +++ b/src/agent/persistence.rs @@ -0,0 +1,471 @@ +//! Session persistence for conversation history +//! +//! This module provides functionality to save and restore chat sessions, +//! enabling users to resume previous conversations. +//! +//! ## Storage Location +//! Sessions are stored in `~/.syncable/sessions//session-{timestamp}-{uuid}.json` +//! +//! ## Features +//! - Automatic session recording on each turn +//! - Session listing and selection by UUID or index +//! - Resume from "latest" or specific session + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::hash_map::DefaultHasher; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::io::{self, BufRead, Write}; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +use super::history::ToolCallRecord; + +/// Represents a complete conversation record stored on disk +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationRecord { + /// Unique session identifier (UUID) + pub session_id: String, + /// Hash of the project path (for organizing sessions by project) + pub project_hash: String, + /// When the session started + pub start_time: DateTime, + /// When the session was last updated + pub last_updated: DateTime, + /// All messages in the conversation + pub messages: Vec, + /// Optional AI-generated summary + pub summary: Option, +} + +/// A single message in the conversation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageRecord { + /// Unique message ID + pub id: String, + /// When the message was created + pub timestamp: DateTime, + /// Who sent the message + pub role: MessageRole, + /// The message content + pub content: String, + /// Tool calls made during this message (for assistant messages) + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, +} + +/// Simplified tool call record for serialization +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializableToolCall { + pub name: String, + pub args_summary: String, + pub result_summary: String, +} + +impl From<&ToolCallRecord> for SerializableToolCall { + fn from(tc: &ToolCallRecord) -> Self { + Self { + name: tc.tool_name.clone(), + args_summary: tc.args_summary.clone(), + result_summary: tc.result_summary.clone(), + } + } +} + +/// Role of the message sender +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum MessageRole { + User, + Assistant, + System, +} + +/// Session information for display and selection +#[derive(Debug, Clone)] +pub struct SessionInfo { + /// Unique session ID + pub id: String, + /// Path to the session file + pub file_path: PathBuf, + /// When the session started + pub start_time: DateTime, + /// When the session was last updated + pub last_updated: DateTime, + /// Number of messages + pub message_count: usize, + /// Display name (first user message or summary) + pub display_name: String, + /// 1-based index for selection + pub index: usize, +} + +/// Records conversations to disk +pub struct SessionRecorder { + session_id: String, + file_path: PathBuf, + record: ConversationRecord, +} + +impl SessionRecorder { + /// Create a new session recorder for the given project + pub fn new(project_path: &Path) -> Self { + let session_id = Uuid::new_v4().to_string(); + let project_hash = hash_project_path(project_path); + let start_time = Utc::now(); + + // Format: session-{timestamp}-{uuid_short}.json + let timestamp = start_time.format("%Y%m%d-%H%M%S").to_string(); + let uuid_short = &session_id[..8]; + let filename = format!("session-{}-{}.json", timestamp, uuid_short); + + // Storage location: ~/.syncable/sessions// + let sessions_dir = get_sessions_dir(&project_hash); + let file_path = sessions_dir.join(filename); + + let record = ConversationRecord { + session_id: session_id.clone(), + project_hash, + start_time, + last_updated: start_time, + messages: Vec::new(), + summary: None, + }; + + Self { + session_id, + file_path, + record, + } + } + + /// Get the session ID + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// Record a user message + pub fn record_user_message(&mut self, content: &str) { + let message = MessageRecord { + id: Uuid::new_v4().to_string(), + timestamp: Utc::now(), + role: MessageRole::User, + content: content.to_string(), + tool_calls: None, + }; + self.record.messages.push(message); + self.record.last_updated = Utc::now(); + } + + /// Record an assistant message with optional tool calls + pub fn record_assistant_message( + &mut self, + content: &str, + tool_calls: Option<&[ToolCallRecord]>, + ) { + let serializable_tools = + tool_calls.map(|calls| calls.iter().map(SerializableToolCall::from).collect()); + + let message = MessageRecord { + id: Uuid::new_v4().to_string(), + timestamp: Utc::now(), + role: MessageRole::Assistant, + content: content.to_string(), + tool_calls: serializable_tools, + }; + self.record.messages.push(message); + self.record.last_updated = Utc::now(); + } + + /// Save the session to disk + pub fn save(&self) -> io::Result<()> { + // Ensure directory exists + if let Some(parent) = self.file_path.parent() { + fs::create_dir_all(parent)?; + } + + // Write JSON + let json = serde_json::to_string_pretty(&self.record)?; + fs::write(&self.file_path, json)?; + Ok(()) + } + + /// Check if the session has any messages + pub fn has_messages(&self) -> bool { + !self.record.messages.is_empty() + } + + /// Get the number of messages + pub fn message_count(&self) -> usize { + self.record.messages.len() + } +} + +/// Selects and loads sessions +pub struct SessionSelector { + #[allow(dead_code)] + project_path: PathBuf, + project_hash: String, +} + +impl SessionSelector { + /// Create a new session selector for the given project + pub fn new(project_path: &Path) -> Self { + let project_hash = hash_project_path(project_path); + Self { + project_path: project_path.to_path_buf(), + project_hash, + } + } + + /// List all available sessions for this project, sorted by most recent first + pub fn list_sessions(&self) -> Vec { + let sessions_dir = get_sessions_dir(&self.project_hash); + if !sessions_dir.exists() { + return Vec::new(); + } + + let mut sessions: Vec = fs::read_dir(&sessions_dir) + .ok() + .into_iter() + .flatten() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json")) + .filter_map(|entry| self.load_session_info(&entry.path())) + .collect(); + + // Sort by last_updated, most recent first + sessions.sort_by(|a, b| b.last_updated.cmp(&a.last_updated)); + + // Assign 1-based indices + for (i, session) in sessions.iter_mut().enumerate() { + session.index = i + 1; + } + + sessions + } + + /// Find a session by identifier (UUID, partial UUID, or numeric index) + pub fn find_session(&self, identifier: &str) -> Option { + let sessions = self.list_sessions(); + + // Try to parse as numeric index first + if let Ok(index) = identifier.parse::() { + if index > 0 && index <= sessions.len() { + return sessions.into_iter().nth(index - 1); + } + } + + // Try to find by UUID or partial UUID + sessions + .into_iter() + .find(|s| s.id == identifier || s.id.starts_with(identifier)) + } + + /// Resolve "latest" or specific identifier to a session + pub fn resolve_session(&self, arg: &str) -> Option { + if arg == "latest" { + self.list_sessions().into_iter().next() + } else { + self.find_session(arg) + } + } + + /// Load a full conversation record from a session + pub fn load_conversation(&self, session_info: &SessionInfo) -> io::Result { + let content = fs::read_to_string(&session_info.file_path)?; + serde_json::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) + } + + /// Load session info from a file path + fn load_session_info(&self, file_path: &Path) -> Option { + let content = fs::read_to_string(file_path).ok()?; + let record: ConversationRecord = serde_json::from_str(&content).ok()?; + + // Get display name from summary or first user message + let display_name = record.summary.clone().unwrap_or_else(|| { + record + .messages + .iter() + .find(|m| m.role == MessageRole::User) + .map(|m| truncate_message(&m.content, 60)) + .unwrap_or_else(|| "Empty session".to_string()) + }); + + Some(SessionInfo { + id: record.session_id, + file_path: file_path.to_path_buf(), + start_time: record.start_time, + last_updated: record.last_updated, + message_count: record.messages.len(), + display_name, + index: 0, // Will be set by list_sessions + }) + } +} + +/// Get the sessions directory for a project +fn get_sessions_dir(project_hash: &str) -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".syncable") + .join("sessions") + .join(project_hash) +} + +/// Hash a project path to create a consistent directory name +fn hash_project_path(project_path: &Path) -> String { + let canonical = project_path + .canonicalize() + .unwrap_or_else(|_| project_path.to_path_buf()); + let mut hasher = DefaultHasher::new(); + canonical.hash(&mut hasher); + format!("{:016x}", hasher.finish())[..8].to_string() +} + +/// Truncate a message for display +fn truncate_message(msg: &str, max_len: usize) -> String { + // Clean up the message + let clean = msg.lines().next().unwrap_or(msg).trim(); + + if clean.len() <= max_len { + clean.to_string() + } else { + format!("{}...", &clean[..max_len.saturating_sub(3)]) + } +} + +/// Format relative time for display +pub fn format_relative_time(time: DateTime) -> String { + let now = Utc::now(); + let duration = now.signed_duration_since(time); + + if duration.num_seconds() < 60 { + "just now".to_string() + } else if duration.num_minutes() < 60 { + let mins = duration.num_minutes(); + format!("{}m ago", mins) + } else if duration.num_hours() < 24 { + let hours = duration.num_hours(); + format!("{}h ago", hours) + } else if duration.num_days() < 30 { + let days = duration.num_days(); + format!("{}d ago", days) + } else { + time.format("%Y-%m-%d").to_string() + } +} + +/// Display an interactive session browser and return the selected session +pub fn browse_sessions(project_path: &Path) -> Option { + use colored::Colorize; + + let selector = SessionSelector::new(project_path); + let sessions = selector.list_sessions(); + + if sessions.is_empty() { + println!( + "{}", + "No previous sessions found for this project.".yellow() + ); + return None; + } + + // Show sessions + println!(); + println!( + "{}", + format!("Recent Sessions ({})", sessions.len()) + .cyan() + .bold() + ); + println!(); + + for session in &sessions { + let time = format_relative_time(session.last_updated); + let msg_count = session.message_count; + + println!( + " {} {} {}", + format!("[{}]", session.index).cyan(), + session.display_name.white(), + format!("({})", time).dimmed() + ); + println!(" {} messages", msg_count.to_string().dimmed()); + } + + println!(); + print!( + "{}", + "Enter number to resume, or press Enter to cancel: ".dimmed() + ); + io::stdout().flush().ok()?; + + // Read user input + let mut input = String::new(); + io::stdin().lock().read_line(&mut input).ok()?; + let input = input.trim(); + + if input.is_empty() { + return None; + } + + selector.find_session(input) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_session_recorder() { + let temp_dir = tempdir().unwrap(); + let project_path = temp_dir.path(); + + let mut recorder = SessionRecorder::new(project_path); + assert!(!recorder.has_messages()); + + recorder.record_user_message("Hello, world!"); + assert!(recorder.has_messages()); + assert_eq!(recorder.message_count(), 1); + + recorder.record_assistant_message("Hello! How can I help?", None); + assert_eq!(recorder.message_count(), 2); + + // Save and verify + recorder.save().unwrap(); + assert!(recorder.file_path.exists()); + } + + #[test] + fn test_project_hash() { + let hash1 = hash_project_path(Path::new("/tmp/project1")); + let hash2 = hash_project_path(Path::new("/tmp/project2")); + let hash3 = hash_project_path(Path::new("/tmp/project1")); + + assert_eq!(hash1.len(), 8); + assert_ne!(hash1, hash2); + assert_eq!(hash1, hash3); + } + + #[test] + fn test_truncate_message() { + assert_eq!(truncate_message("short", 10), "short"); + assert_eq!(truncate_message("this is a long message", 10), "this is..."); + assert_eq!(truncate_message("line1\nline2\nline3", 100), "line1"); + } + + #[test] + fn test_format_relative_time() { + let now = Utc::now(); + assert_eq!(format_relative_time(now), "just now"); + + let hour_ago = now - chrono::Duration::hours(1); + assert_eq!(format_relative_time(hour_ago), "1h ago"); + + let day_ago = now - chrono::Duration::days(1); + assert_eq!(format_relative_time(day_ago), "1d ago"); + } +} diff --git a/src/agent/prompts/mod.rs b/src/agent/prompts/mod.rs index 3614ea9c..9c435f4a 100644 --- a/src/agent/prompts/mod.rs +++ b/src/agent/prompts/mod.rs @@ -13,9 +13,56 @@ //! - - How to handle errors without self-doubt //! - - How to reason without "oops" patterns -/// Docker generation prompt with self-correction protocol +/// Docker generation prompt with self-correction protocol (full reference) pub const DOCKER_GENERATION: &str = include_str!("docker_self_correct.md"); +/// Docker validation protocol - appended to prompts when Dockerfile queries are detected +const DOCKER_VALIDATION_PROTOCOL: &str = r#" + +**CRITICAL: When creating or modifying Dockerfiles, you MUST NOT stop after writing the file.** + +## Mandatory Validation Sequence +After writing any Dockerfile or docker-compose.yml, execute this sequence IN ORDER: + +1. **Lint with hadolint** (native tool): + - Use `hadolint` tool (NOT shell hadolint) + - If errors: fix the file, re-run hadolint + - Continue only when lint passes + +2. **Validate compose config** (if docker-compose.yml exists): + - Run: `shell("docker compose config")` + - If errors: fix the file, re-run + +3. **Build the image**: + - Run: `shell("docker build -t :test .")` or `shell("docker compose build")` + - This is NOT optional - you MUST build to verify the Dockerfile works + - If build fails: analyze error, fix Dockerfile, restart from step 1 + +4. **Test the container** (if applicable): + - Run: `shell("docker compose up -d")` or `shell("docker run -d --name test- :test")` + - Wait: `shell("sleep 3")` + - Verify: `shell("docker compose ps")` or `shell("docker ps | grep test-")` + - If container is not running/healthy: check logs, fix, rebuild + +5. **Cleanup** (if test was successful): + - Run: `shell("docker compose down")` or `shell("docker rm -f test-")` + +## Error Handling +- If ANY step fails, analyze the error and fix the artifact +- After fixing, restart the validation sequence from step 1 (hadolint) +- If the same error persists after 2 attempts, report the issue to the user + +## Success Criteria +The task is ONLY complete when: +- Dockerfile passes hadolint validation +- docker-compose.yml passes config validation (if present) +- Image builds successfully +- Container runs without immediate crash + +Do NOT ask the user "should I build this?" - just build it as part of the validation. + +"#; + /// Agent identity section - DevOps/Platform/Security specialization const AGENT_IDENTITY: &str = r#" @@ -108,6 +155,41 @@ const THINKING_GUIDELINES: &str = r#" "#; +/// IaC tool selection rules - CRITICAL for ensuring native tools are used +const IAC_TOOL_SELECTION_RULES: &str = r#" + +**CRITICAL: Use NATIVE tools - DO NOT use shell commands** + +## File Discovery (NOT shell find/ls/grep) +| Task | USE THIS | DO NOT USE | +|------|----------|------------| +| List files | `list_directory` | shell(ls...), shell(find...) | +| Understand structure | `analyze_project(path: "folder")` | shell(tree...), shell(find...) | +| Read file | `read_file` | shell(cat...), shell(head...) | + +**analyze_project tips:** +- For project overview: `analyze_project()` on root is fine +- For specific folder: use `path` parameter: `analyze_project(path: "tests/test-lint")` +- Be context-aware: if user gave specific folders, analyze those, not root + +## IaC Linting (NOT shell linting commands) +| File Type | USE THIS TOOL | DO NOT USE | +|-----------|---------------|------------| +| Dockerfile | `hadolint` | shell(hadolint...), shell(docker...) | +| docker-compose.yml | `dclint` | shell(docker-compose config...) | +| Kubernetes YAML | `kubelint` | shell(kubectl...), shell(kubeval...) | +| Helm charts | `helmlint` + `kubelint` | shell(helm lint...) | + +**WHY native tools:** +- AI-optimized JSON with priorities and fix recommendations +- No external binaries needed (self-contained) +- Faster (no process spawn) +- Consistent output format + +Shell should ONLY be used for: docker build, terraform commands, make/npm run/cargo build, git + +"#; + /// Get system information section fn get_system_info(project_path: &std::path::Path) -> String { format!( @@ -139,6 +221,8 @@ pub fn get_analysis_prompt(project_path: &std::path::Path) -> String { {thinking} +{iac_tool_rules} + You have access to tools to help analyze and understand the project: @@ -206,7 +290,8 @@ Task status in plan files: tool_usage = TOOL_USAGE_INSTRUCTIONS, non_negotiable = NON_NEGOTIABLE_RULES, error_protocol = ERROR_REFLECTION_PROTOCOL, - thinking = THINKING_GUIDELINES + thinking = THINKING_GUIDELINES, + iac_tool_rules = IAC_TOOL_SELECTION_RULES ) } @@ -225,6 +310,8 @@ pub fn get_code_development_prompt(project_path: &std::path::Path) -> String { {thinking} +{iac_tool_rules} + **Analysis Tools:** - analyze_project - Analyze project structure, languages, dependencies @@ -289,13 +376,15 @@ Don't endlessly analyze - make progress by writing. tool_usage = TOOL_USAGE_INSTRUCTIONS, non_negotiable = NON_NEGOTIABLE_RULES, error_protocol = ERROR_REFLECTION_PROTOCOL, - thinking = THINKING_GUIDELINES + thinking = THINKING_GUIDELINES, + iac_tool_rules = IAC_TOOL_SELECTION_RULES ) } /// Get the DevOps generation prompt (Docker, Terraform, Helm, K8s) -pub fn get_devops_prompt(project_path: &std::path::Path) -> String { - format!( +/// If query is provided and is a Dockerfile-related query, appends the Docker validation protocol +pub fn get_devops_prompt(project_path: &std::path::Path, query: Option<&str>) -> String { + let base_prompt = format!( r#"{system_info} {agent_identity} @@ -308,6 +397,8 @@ pub fn get_devops_prompt(project_path: &std::path::Path) -> String { {thinking} +{iac_tool_rules} + **Analysis Tools:** - analyze_project - Detect languages, frameworks, dependencies, build commands @@ -419,8 +510,16 @@ When the user says "execute the plan" or similar: tool_usage = TOOL_USAGE_INSTRUCTIONS, non_negotiable = NON_NEGOTIABLE_RULES, error_protocol = ERROR_REFLECTION_PROTOCOL, - thinking = THINKING_GUIDELINES - ) + thinking = THINKING_GUIDELINES, + iac_tool_rules = IAC_TOOL_SELECTION_RULES + ); + + // Append Docker validation protocol if this is a Dockerfile-related query + if query.is_some_and(is_dockerfile_query) { + format!("{}\n\n{}", base_prompt, DOCKER_VALIDATION_PROTOCOL) + } else { + base_prompt + } } /// Get prompt for Terraform-specific generation @@ -539,22 +638,24 @@ pub fn get_planning_prompt(project_path: &std::path::Path) -> String { {tool_usage} +{iac_tool_rules} + **PLAN MODE ACTIVE** - You are in read-only exploration mode. ## What You CAN Do: -- Read and analyze files using read_file -- List directories using list_directory -- Run read-only shell commands: ls, cat, head, tail, grep, find, git status, git log, git diff +- Read files using `read_file` (PREFERRED over shell cat/head/tail) +- List directories using `list_directory` (PREFERRED over shell ls/find) +- Lint IaC files using native tools (hadolint, dclint, kubelint, helmlint) +- Run shell for git commands only: git status, git log, git diff - Analyze project structure and patterns -- Explain code and architecture - **CREATE STRUCTURED PLANS** using plan_create tool -- Answer questions about the codebase ## What You CANNOT Do: - Create or modify source files (write_file, write_files are disabled) - Run write commands (rm, mv, cp, mkdir, echo >, etc.) - Execute build/test commands that modify state +- Use shell for file discovery when user gave explicit paths ## Your Role in Plan Mode: 1. Research thoroughly - read relevant files, understand patterns @@ -562,6 +663,33 @@ pub fn get_planning_prompt(project_path: &std::path::Path) -> String { 3. Create a structured plan using the `plan_create` tool with task checkboxes 4. Tell user to switch to standard mode (Shift+Tab) and say "execute the plan" +## CRITICAL: Plan Scope Rules +**DO NOT over-engineer plans.** Stay focused on what the user explicitly asked. + +### What to INCLUDE in the plan: +- Tasks that directly address the user's request +- All findings from linting/analysis that need fixing +- Quality improvements within the scope (security, best practices) + +### What to EXCLUDE from the plan (unless explicitly requested): +- "Documentation & Standards" phases - don't create README, GUIDE, STANDARDS docs +- "Testing & Validation" phases - don't add CI/CD, test infrastructure, security scanning setup +- "Template Repository" tasks - don't create reference templates +- Anything that goes beyond "analyze and improve" into "establish ongoing processes" + +### When the user says "analyze and improve X": +- Analyze X thoroughly +- Fix all issues found in X +- DONE. Do not add phases for documenting standards or setting up CI/CD. + +### Follow-up suggestions: +Instead of embedding extra phases in the plan, mention them AFTER the plan summary: +"📋 Plan created with X tasks. After completion, you may also want to consider: +- Adding CI/CD validation for these files +- Creating a standards document for team reference" + +This lets the user decide if they want to do more, rather than assuming they do. + ## Creating Plans: Use the `plan_create` tool to create executable plans. Each task must use checkbox format: @@ -575,10 +703,11 @@ Brief description of what we're implementing. - [ ] First task - create/modify this file - [ ] Second task - implement this feature -- [ ] Third task - add tests -- [ ] Fourth task - validate everything works +- [ ] Third task - validate the changes work ``` +Keep plans **concise and actionable**. Group related fixes logically but don't pad with extra phases. + Task status markers: - `[ ]` PENDING - Not started - `[~]` IN_PROGRESS - Currently being worked on @@ -587,29 +716,35 @@ Task status markers: -**Available Tools (Plan Mode):** -- read_file - Read file contents -- list_directory - List files and directories -- shell - Run read-only commands only (ls, cat, grep, find, git status/log/diff) -- analyze_project - Analyze project architecture, dependencies - -**Linting Tools (read-only analysis):** -- hadolint - Lint Dockerfiles for best practices -- dclint - Lint docker-compose files -- kubelint - Lint K8s manifests for security/best practices (works on YAML, Helm charts, Kustomize) -- helmlint - Lint Helm chart structure and templates +**File Discovery (ALWAYS use these, NOT shell find/ls):** +- list_directory - List files in a directory (fast, simple) +- analyze_project - Understand project structure, languages, frameworks + • Root analysis: `analyze_project()` - good for project overview + • Targeted analysis: `analyze_project(path: "folder")` - when user gave specific paths +- read_file - Read file contents (NOT shell cat/head/tail) + +**IaC Linting Tools (ALWAYS use these, NOT shell):** +- hadolint - Lint Dockerfiles (NOT shell hadolint) +- dclint - Lint docker-compose files (NOT shell docker-compose config) +- kubelint - Lint K8s manifests, Helm charts, Kustomize (NOT shell kubectl/kubeval) +- helmlint - Lint Helm chart structure and templates (NOT shell helm lint) **Planning Tools:** - **plan_create** - Create structured plan files with task checkboxes - **plan_list** - List existing plans in plans/ directory +**Shell (use ONLY for git commands):** +- shell - ONLY for: git status, git log, git diff, git show + **NOT Available in Plan Mode:** - write_file, write_files - File creation/modification disabled -- Shell commands that modify files - Blocked +- Shell for file discovery (use list_directory instead) +- Shell for linting (use native tools instead) "#, system_info = get_system_info(project_path), agent_identity = AGENT_IDENTITY, - tool_usage = TOOL_USAGE_INSTRUCTIONS + tool_usage = TOOL_USAGE_INSTRUCTIONS, + iac_tool_rules = IAC_TOOL_SELECTION_RULES ) } @@ -648,6 +783,24 @@ pub fn is_plan_continuation_query(query: &str) -> bool { false } +/// Detect if a query is specifically about Dockerfile creation/modification +pub fn is_dockerfile_query(query: &str) -> bool { + let query_lower = query.to_lowercase(); + let dockerfile_keywords = [ + "dockerfile", + "docker-compose", + "docker compose", + "containerize", + "containerise", + "docker image", + "docker build", + ]; + + dockerfile_keywords + .iter() + .any(|kw| query_lower.contains(kw)) +} + /// Detect if a query is specifically about code development (not DevOps) pub fn is_code_development_query(query: &str) -> bool { let query_lower = query.to_lowercase(); diff --git a/src/agent/session.rs b/src/agent/session.rs index c3ec20a7..59291e4c 100644 --- a/src/agent/session.rs +++ b/src/agent/session.rs @@ -1421,6 +1421,92 @@ impl ChatSession { Ok(()) } + /// Handle /resume command - browse and select a session to resume + pub fn handle_resume_command(&self) -> AgentResult<()> { + use crate::agent::persistence::{SessionSelector, browse_sessions, format_relative_time}; + + let selector = SessionSelector::new(&self.project_path); + let sessions = selector.list_sessions(); + + if sessions.is_empty() { + println!( + "\n{}", + "No previous sessions found for this project.".yellow() + ); + println!( + "{}", + "Sessions are automatically saved during conversations.".dimmed() + ); + return Ok(()); + } + + // Show the interactive browser + if let Some(selected) = browse_sessions(&self.project_path) { + // User selected a session + let time = format_relative_time(selected.last_updated); + println!( + "\n{} Selected: {} ({}, {} messages)", + "✓".green(), + selected.display_name.white().bold(), + time.dimmed(), + selected.message_count + ); + println!( + "{}", + "Note: To load the session, restart with: sync-ctl chat --resume ".dimmed() + ); + println!(" Session ID: {}", selected.id.cyan()); + } + + Ok(()) + } + + /// Handle /sessions command - list available sessions + pub fn handle_list_sessions_command(&self) { + use crate::agent::persistence::{SessionSelector, format_relative_time}; + + let selector = SessionSelector::new(&self.project_path); + let sessions = selector.list_sessions(); + + if sessions.is_empty() { + println!( + "\n{}", + "No previous sessions found for this project.".yellow() + ); + return; + } + + println!( + "\n{}", + format!("📋 Sessions ({})", sessions.len()).cyan().bold() + ); + println!(); + + for session in &sessions { + let time = format_relative_time(session.last_updated); + println!( + " {} {} {}", + format!("[{}]", session.index).cyan(), + session.display_name.white(), + format!("({})", time).dimmed() + ); + println!( + " {} messages · ID: {}", + session.message_count.to_string().dimmed(), + session.id[..8].to_string().dimmed() + ); + } + + println!(); + println!("{}", "To resume a session:".dimmed()); + println!( + " {} or {}", + "/resume".cyan(), + "sync-ctl chat --resume ".cyan() + ); + println!(); + } + /// List all profiles fn list_profiles(&self, config: &crate::config::types::AgentConfig) { let active = config.active_profile.as_deref(); @@ -1747,6 +1833,14 @@ impl ChatSession { "/plans" => { self.handle_plans_command()?; } + "/resume" | "/s" => { + // Resume is handled specially - needs to return session data + // The command just shows the browser, actual loading happens in mod.rs + self.handle_resume_command()?; + } + "/sessions" | "/ls" => { + self.handle_list_sessions_command(); + } _ => { if cmd.starts_with('/') { // Unknown command - interactive picker already handled in read_input @@ -1808,7 +1902,7 @@ impl ChatSession { use crate::agent::ui::input::read_input_with_file_picker; Ok(read_input_with_file_picker( - "You:", + ">", &self.project_path, self.plan_mode.is_planning(), )) diff --git a/src/agent/tools/fetch.rs b/src/agent/tools/fetch.rs new file mode 100644 index 00000000..30b5b4d3 --- /dev/null +++ b/src/agent/tools/fetch.rs @@ -0,0 +1,429 @@ +//! Web fetch tool for retrieving online content +//! +//! Provides the agent with the ability to fetch content from URLs and convert +//! HTML to readable markdown. Inspired by Forge's NetFetch tool. +//! +//! Features: +//! - Fetches HTTP/HTTPS URLs +//! - Converts HTML to markdown for readability +//! - Respects robots.txt (basic check) +//! - Truncates large responses to prevent context overflow +//! - Returns raw content when requested + +use reqwest::{Client, Url}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +/// Maximum content length to return (characters) +const MAX_CONTENT_LENGTH: usize = 40_000; + +// ============================================================================ +// Web Fetch Tool +// ============================================================================ + +#[derive(Debug, Deserialize)] +pub struct WebFetchArgs { + /// URL to fetch + pub url: String, + /// If true, return raw content without markdown conversion (default: false) + pub raw: Option, +} + +#[derive(Debug, thiserror::Error)] +#[error("Web fetch error: {0}")] +pub struct WebFetchError(String); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebFetchTool { + #[serde(skip)] + client: Option, +} + +impl Default for WebFetchTool { + fn default() -> Self { + Self::new() + } +} + +impl WebFetchTool { + pub fn new() -> Self { + Self { + client: Some( + Client::builder() + .user_agent("Mozilla/5.0 (compatible; SyncableCLI/0.1; +https://syncable.dev)") + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap_or_default(), + ), + } + } + + fn client(&self) -> Client { + self.client.clone().unwrap_or_default() + } + + /// Check robots.txt for disallowed paths (basic check) + async fn check_robots_txt(&self, url: &Url) -> Result<(), WebFetchError> { + let robots_url = format!("{}://{}/robots.txt", url.scheme(), url.authority()); + + // Try to fetch robots.txt (ignore errors - many sites don't have one) + if let Ok(response) = self.client().get(&robots_url).send().await { + if response.status().is_success() { + if let Ok(robots_content) = response.text().await { + let path = url.path(); + for line in robots_content.lines() { + if let Some(disallowed) = line.strip_prefix("Disallow: ") { + let disallowed = disallowed.trim(); + if !disallowed.is_empty() { + let disallowed = if !disallowed.starts_with('/') { + format!("/{}", disallowed) + } else { + disallowed.to_string() + }; + let check_path = if !path.starts_with('/') { + format!("/{}", path) + } else { + path.to_string() + }; + if check_path.starts_with(&disallowed) { + return Err(WebFetchError(format!( + "URL {} cannot be fetched due to robots.txt restrictions", + url + ))); + } + } + } + } + } + } + } + Ok(()) + } + + /// Fetch URL content and optionally convert HTML to markdown + async fn fetch_url(&self, url: &Url, force_raw: bool) -> Result { + // Check robots.txt first + self.check_robots_txt(url).await?; + + let response = self + .client() + .get(url.as_str()) + .send() + .await + .map_err(|e| WebFetchError(format!("Failed to fetch URL {}: {}", url, e)))?; + + let status = response.status(); + if !status.is_success() { + return Err(WebFetchError(format!( + "Failed to fetch {} - status code {}", + url, status + ))); + } + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let raw_content = response + .text() + .await + .map_err(|e| WebFetchError(format!("Failed to read response from {}: {}", url, e)))?; + + // Determine if content is HTML + let is_html = raw_content[..100.min(raw_content.len())].contains(" MAX_CONTENT_LENGTH { + ( + content[..MAX_CONTENT_LENGTH].to_string() + "\n\n[Content truncated...]", + true, + ) + } else { + (content, false) + }; + + Ok(FetchResult { + content, + content_type, + status_code: status.as_u16(), + was_truncated, + was_html: is_html && !force_raw, + }) + } +} + +#[derive(Debug)] +struct FetchResult { + content: String, + content_type: String, + status_code: u16, + was_truncated: bool, + was_html: bool, +} + +impl Tool for WebFetchTool { + const NAME: &'static str = "web_fetch"; + + type Error = WebFetchError; + type Args = WebFetchArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: r#"Fetch content from a URL and return it as text or markdown. + +Use this tool to: +- Look up documentation for libraries, frameworks, or APIs +- Check official guides and tutorials +- Verify information from authoritative sources +- Research best practices and patterns +- Access API reference documentation +- Get current information beyond training data + +The tool automatically converts HTML pages to readable markdown format. +For API endpoints returning JSON/XML, use raw=true to get the unprocessed response. + +Limitations: +- Cannot access pages requiring authentication +- Respects robots.txt restrictions +- Large pages are truncated to ~40,000 characters +- Some sites may block automated requests"# + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to fetch (must be http:// or https://)" + }, + "raw": { + "type": "boolean", + "description": "If true, return raw content without HTML-to-markdown conversion. Default: false" + } + }, + "required": ["url"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + // Parse and validate URL + let url = Url::parse(&args.url) + .map_err(|e| WebFetchError(format!("Invalid URL '{}': {}", args.url, e)))?; + + // Only allow http/https + if url.scheme() != "http" && url.scheme() != "https" { + return Err(WebFetchError(format!( + "Unsupported URL scheme '{}'. Only http and https are supported.", + url.scheme() + ))); + } + + let force_raw = args.raw.unwrap_or(false); + let result = self.fetch_url(&url, force_raw).await?; + + let output = json!({ + "url": args.url, + "status_code": result.status_code, + "content_type": result.content_type, + "converted_to_markdown": result.was_html, + "truncated": result.was_truncated, + "content": result.content + }); + + serde_json::to_string_pretty(&output) + .map_err(|e| WebFetchError(format!("Failed to serialize response: {}", e))) + } +} + +/// Convert HTML content to Markdown +/// +/// Uses a simple regex-based approach for common HTML elements. +/// For more complex HTML, consider using a proper HTML parser. +fn html_to_markdown(html: &str) -> String { + use regex::Regex; + + let mut content = html.to_string(); + + // Remove script and style tags entirely + let script_re = Regex::new(r"(?is)]*>.*?").unwrap(); + content = script_re.replace_all(&content, "").to_string(); + + let style_re = Regex::new(r"(?is)]*>.*?").unwrap(); + content = style_re.replace_all(&content, "").to_string(); + + // Remove comments + let comment_re = Regex::new(r"(?is)").unwrap(); + content = comment_re.replace_all(&content, "").to_string(); + + // Convert headers + let h1_re = Regex::new(r"(?is)]*>(.*?)").unwrap(); + content = h1_re.replace_all(&content, "\n# $1\n").to_string(); + + let h2_re = Regex::new(r"(?is)]*>(.*?)").unwrap(); + content = h2_re.replace_all(&content, "\n## $1\n").to_string(); + + let h3_re = Regex::new(r"(?is)]*>(.*?)").unwrap(); + content = h3_re.replace_all(&content, "\n### $1\n").to_string(); + + let h4_re = Regex::new(r"(?is)]*>(.*?)").unwrap(); + content = h4_re.replace_all(&content, "\n#### $1\n").to_string(); + + let h5_re = Regex::new(r"(?is)]*>(.*?)").unwrap(); + content = h5_re.replace_all(&content, "\n##### $1\n").to_string(); + + let h6_re = Regex::new(r"(?is)]*>(.*?)").unwrap(); + content = h6_re.replace_all(&content, "\n###### $1\n").to_string(); + + // Convert paragraphs + let p_re = Regex::new(r"(?is)]*>(.*?)

").unwrap(); + content = p_re.replace_all(&content, "\n$1\n").to_string(); + + // Convert links + let a_re = Regex::new(r#"(?is)]*href="([^"]*)"[^>]*>(.*?)"#).unwrap(); + content = a_re.replace_all(&content, "[$2]($1)").to_string(); + + // Convert bold/strong + let strong_re = Regex::new(r"(?is)<(?:strong|b)[^>]*>(.*?)").unwrap(); + content = strong_re.replace_all(&content, "**$1**").to_string(); + + // Convert italic/em + let em_re = Regex::new(r"(?is)<(?:em|i)[^>]*>(.*?)").unwrap(); + content = em_re.replace_all(&content, "*$1*").to_string(); + + // Convert code blocks + let pre_re = Regex::new(r"(?is)]*>]*>(.*?)").unwrap(); + content = pre_re.replace_all(&content, "\n```\n$1\n```\n").to_string(); + + let pre_only_re = Regex::new(r"(?is)]*>(.*?)").unwrap(); + content = pre_only_re + .replace_all(&content, "\n```\n$1\n```\n") + .to_string(); + + // Convert inline code + let code_re = Regex::new(r"(?is)]*>(.*?)").unwrap(); + content = code_re.replace_all(&content, "`$1`").to_string(); + + // Convert lists + let ul_re = Regex::new(r"(?is)]*>(.*?)").unwrap(); + content = ul_re.replace_all(&content, "\n$1\n").to_string(); + + let ol_re = Regex::new(r"(?is)]*>(.*?)").unwrap(); + content = ol_re.replace_all(&content, "\n$1\n").to_string(); + + let li_re = Regex::new(r"(?is)]*>(.*?)").unwrap(); + content = li_re.replace_all(&content, "- $1\n").to_string(); + + // Convert blockquotes + let bq_re = Regex::new(r"(?is)]*>(.*?)").unwrap(); + content = bq_re.replace_all(&content, "\n> $1\n").to_string(); + + // Convert line breaks + let br_re = Regex::new(r"(?i)").unwrap(); + content = br_re.replace_all(&content, "\n").to_string(); + + // Convert horizontal rules + let hr_re = Regex::new(r"(?i)").unwrap(); + content = hr_re.replace_all(&content, "\n---\n").to_string(); + + // Remove remaining HTML tags + let tag_re = Regex::new(r"<[^>]+>").unwrap(); + content = tag_re.replace_all(&content, "").to_string(); + + // Decode common HTML entities + content = content + .replace(" ", " ") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("©", "©") + .replace("®", "®") + .replace("™", "™") + .replace("—", "—") + .replace("–", "–") + .replace("…", "…"); + + // Clean up excessive whitespace + let multiline_re = Regex::new(r"\n{3,}").unwrap(); + content = multiline_re.replace_all(&content, "\n\n").to_string(); + + let space_re = Regex::new(r" {2,}").unwrap(); + content = space_re.replace_all(&content, " ").to_string(); + + content.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_html_to_markdown_headers() { + let html = "

Title

Subtitle

Section

"; + let md = html_to_markdown(html); + assert!(md.contains("# Title")); + assert!(md.contains("## Subtitle")); + assert!(md.contains("### Section")); + } + + #[test] + fn test_html_to_markdown_links() { + let html = r#"Example"#; + let md = html_to_markdown(html); + assert!(md.contains("[Example](https://example.com)")); + } + + #[test] + fn test_html_to_markdown_formatting() { + let html = "bold and italic"; + let md = html_to_markdown(html); + assert!(md.contains("**bold**")); + assert!(md.contains("*italic*")); + } + + #[test] + fn test_html_to_markdown_code() { + let html = "inline and
block
"; + let md = html_to_markdown(html); + assert!(md.contains("`inline`")); + assert!(md.contains("```")); + } + + #[test] + fn test_html_to_markdown_lists() { + let html = "
  • Item 1
  • Item 2
"; + let md = html_to_markdown(html); + assert!(md.contains("- Item 1")); + assert!(md.contains("- Item 2")); + } + + #[test] + fn test_html_to_markdown_removes_scripts() { + let html = "

Content

More

"; + let md = html_to_markdown(html); + assert!(!md.contains("script")); + assert!(!md.contains("alert")); + assert!(md.contains("Content")); + assert!(md.contains("More")); + } +} diff --git a/src/agent/tools/helmlint.rs b/src/agent/tools/helmlint.rs index 31a9e83c..d4e52563 100644 --- a/src/agent/tools/helmlint.rs +++ b/src/agent/tools/helmlint.rs @@ -18,8 +18,8 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use std::path::PathBuf; -use crate::analyzer::helmlint::{lint_chart, HelmlintConfig, LintResult, Severity}; use crate::analyzer::helmlint::types::RuleCategory; +use crate::analyzer::helmlint::{HelmlintConfig, LintResult, Severity, lint_chart}; /// Arguments for the helmlint tool #[derive(Debug, Deserialize)] @@ -94,7 +94,9 @@ impl HelmlintTool { "HL1001" => "Create a Chart.yaml file in the chart root directory.", "HL1002" => "Add 'apiVersion: v2' (for Helm 3) or 'apiVersion: v1' to Chart.yaml.", "HL1003" => "Add a 'name' field to Chart.yaml matching the chart directory name.", - "HL1004" => "Add a 'version' field with semantic versioning (e.g., '1.0.0') to Chart.yaml.", + "HL1004" => { + "Add a 'version' field with semantic versioning (e.g., '1.0.0') to Chart.yaml." + } "HL1005" => "Use semantic versioning format (MAJOR.MINOR.PATCH) for the version field.", "HL1006" => "Add a 'description' field explaining what the chart does.", "HL1007" => "Add a 'maintainers' list with name and email for chart ownership.", @@ -430,7 +432,12 @@ spec: let result = tool.call(args).await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("No chart specified")); + assert!( + result + .unwrap_err() + .to_string() + .contains("No chart specified") + ); } #[tokio::test] diff --git a/src/agent/tools/kubelint.rs b/src/agent/tools/kubelint.rs index a1213c83..34d2fbf5 100644 --- a/src/agent/tools/kubelint.rs +++ b/src/agent/tools/kubelint.rs @@ -18,7 +18,7 @@ use serde_json::json; use std::path::PathBuf; use crate::analyzer::kubelint::{ - lint, lint_content, lint_file, KubelintConfig, LintResult, Severity, + KubelintConfig, LintResult, Severity, lint, lint_content, lint_file, }; /// Arguments for the kubelint tool @@ -86,35 +86,62 @@ impl KubelintTool { fn get_check_category(code: &str) -> &'static str { match code { // Security checks - "privileged-container" | "privilege-escalation" | "run-as-non-root" - | "read-only-root-fs" | "drop-net-raw-capability" | "hostnetwork" | "hostpid" - | "hostipc" | "host-mounts" | "writable-host-mount" | "docker-sock" - | "unsafe-proc-mount" | "scc-deny-privileged-container" => "security", + "privileged-container" + | "privilege-escalation" + | "run-as-non-root" + | "read-only-root-fs" + | "drop-net-raw-capability" + | "hostnetwork" + | "hostpid" + | "hostipc" + | "host-mounts" + | "writable-host-mount" + | "docker-sock" + | "unsafe-proc-mount" + | "scc-deny-privileged-container" => "security", // Best practice checks - "latest-tag" | "no-liveness-probe" | "no-readiness-probe" | "unset-cpu-requirements" - | "unset-memory-requirements" | "minimum-replicas" | "no-anti-affinity" - | "no-rolling-update-strategy" | "default-service-account" - | "deprecated-service-account" | "env-var-secret" | "read-secret-from-env-var" - | "priority-class-name" | "no-node-affinity" | "restart-policy" | "sysctls" + "latest-tag" + | "no-liveness-probe" + | "no-readiness-probe" + | "unset-cpu-requirements" + | "unset-memory-requirements" + | "minimum-replicas" + | "no-anti-affinity" + | "no-rolling-update-strategy" + | "default-service-account" + | "deprecated-service-account" + | "env-var-secret" + | "read-secret-from-env-var" + | "priority-class-name" + | "no-node-affinity" + | "restart-policy" + | "sysctls" | "dnsconfig-options" => "best-practice", // RBAC checks - "access-to-secrets" | "access-to-create-pods" | "cluster-admin-role-binding" + "access-to-secrets" + | "access-to-create-pods" + | "cluster-admin-role-binding" | "wildcard-in-rules" => "rbac", // Validation checks - "dangling-service" | "dangling-ingress" | "dangling-horizontalpodautoscaler" - | "dangling-networkpolicy" | "mismatching-selector" | "duplicate-env-var" - | "invalid-target-ports" | "non-existent-service-account" | "non-isolated-pod" - | "use-namespace" | "env-var-value-from" | "job-ttl-seconds-after-finished" => { - "validation" - } + "dangling-service" + | "dangling-ingress" + | "dangling-horizontalpodautoscaler" + | "dangling-networkpolicy" + | "mismatching-selector" + | "duplicate-env-var" + | "invalid-target-ports" + | "non-existent-service-account" + | "non-isolated-pod" + | "use-namespace" + | "env-var-value-from" + | "job-ttl-seconds-after-finished" => "validation", // Port checks - "ssh-port" | "privileged-ports" | "liveness-port" | "readiness-port" | "startup-port" => { - "ports" - } + "ssh-port" | "privileged-ports" | "liveness-port" | "readiness-port" + | "startup-port" => "ports", // PDB checks "pdb-max-unavailable" | "pdb-min-available" | "pdb-unhealthy-pod-eviction-policy" => { @@ -383,8 +410,8 @@ impl Tool for KubelintTool { "deployment", "helm", "charts", - "test-lint", // For testing - "test-lint/k8s", // For testing + "test-lint", // For testing + "test-lint/k8s", // For testing ".", ]; @@ -402,14 +429,12 @@ impl Tool for KubelintTool { } // Check for YAML files if let Ok(entries) = std::fs::read_dir(&candidate_path) { - let has_yaml = entries - .filter_map(|e| e.ok()) - .any(|e| { - e.path() - .extension() - .map(|ext| ext == "yaml" || ext == "yml") - .unwrap_or(false) - }); + let has_yaml = entries.filter_map(|e| e.ok()).any(|e| { + e.path() + .extension() + .map(|ext| ext == "yaml" || ext == "yml") + .unwrap_or(false) + }); if has_yaml { found = Some((candidate_path, candidate.to_string())); break; @@ -471,10 +496,7 @@ spec: let args = KubelintArgs { path: None, content: Some(yaml.to_string()), - include: vec![ - "privileged-container".to_string(), - "latest-tag".to_string(), - ], + include: vec!["privileged-container".to_string(), "latest-tag".to_string()], exclude: vec![], threshold: None, }; @@ -523,10 +545,7 @@ spec: let args = KubelintArgs { path: None, content: Some(yaml.to_string()), - include: vec![ - "privileged-container".to_string(), - "latest-tag".to_string(), - ], + include: vec!["privileged-container".to_string(), "latest-tag".to_string()], exclude: vec![], threshold: None, }; @@ -582,7 +601,12 @@ spec: let result = tool.call(args).await.unwrap(); let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); - assert!(parsed["source"].as_str().unwrap().contains("deployment.yaml")); + assert!( + parsed["source"] + .as_str() + .unwrap() + .contains("deployment.yaml") + ); assert!(parsed["summary"]["objects_analyzed"].as_u64().unwrap_or(0) >= 1); } @@ -613,7 +637,7 @@ spec: let args = KubelintArgs { path: None, content: Some(yaml.to_string()), - include: vec![], // Use all defaults + builtin + include: vec![], // Use all defaults + builtin exclude: vec![], threshold: None, }; @@ -624,11 +648,19 @@ spec: let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); // Verify structure - assert!(parsed["summary"]["total_issues"].as_u64().unwrap() > 0, - "Expected issues but got none. Output: {}", result); - assert!(!parsed["action_plan"]["critical"].as_array().unwrap().is_empty() || - !parsed["action_plan"]["high"].as_array().unwrap().is_empty(), - "Expected critical or high priority issues"); + assert!( + parsed["summary"]["total_issues"].as_u64().unwrap() > 0, + "Expected issues but got none. Output: {}", + result + ); + assert!( + !parsed["action_plan"]["critical"] + .as_array() + .unwrap() + .is_empty() + || !parsed["action_plan"]["high"].as_array().unwrap().is_empty(), + "Expected critical or high priority issues" + ); } #[tokio::test] @@ -659,10 +691,7 @@ spec: path: None, content: Some(yaml.to_string()), include: vec![], - exclude: vec![ - "privileged-container".to_string(), - "latest-tag".to_string(), - ], + exclude: vec!["privileged-container".to_string(), "latest-tag".to_string()], threshold: None, }; @@ -680,9 +709,11 @@ spec: }) .collect(); - assert!(!all_issues - .iter() - .any(|i| i["check"] == "privileged-container")); + assert!( + !all_issues + .iter() + .any(|i| i["check"] == "privileged-container") + ); assert!(!all_issues.iter().any(|i| i["check"] == "latest-tag")); } } diff --git a/src/agent/tools/mod.rs b/src/agent/tools/mod.rs index 04e337d0..4a4d4c53 100644 --- a/src/agent/tools/mod.rs +++ b/src/agent/tools/mod.rs @@ -47,9 +47,13 @@ //! - `PlanUpdateTool` - Update task status (done, failed) //! - `PlanListTool` - List all available plan files //! +//! ### Web +//! - `WebFetchTool` - Fetch content from URLs (converts HTML to markdown) +//! mod analyze; mod dclint; mod diagnostics; +mod fetch; mod file_ops; mod hadolint; mod helmlint; @@ -65,6 +69,7 @@ pub use truncation::TruncationLimits; pub use analyze::AnalyzeTool; pub use dclint::DclintTool; pub use diagnostics::DiagnosticsTool; +pub use fetch::WebFetchTool; pub use file_ops::{ListDirectoryTool, ReadFileTool, WriteFileTool, WriteFilesTool}; pub use hadolint::HadolintTool; pub use helmlint::HelmlintTool; diff --git a/src/agent/tools/shell.rs b/src/agent/tools/shell.rs index fc4d7ea3..6b891fbe 100644 --- a/src/agent/tools/shell.rs +++ b/src/agent/tools/shell.rs @@ -272,21 +272,22 @@ impl Tool for ShellTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { name: Self::NAME.to_string(), - description: r#"Execute shell commands for validation and building. This tool is restricted to safe DevOps commands. - -Allowed commands: -- Docker: docker build, docker compose -- Terraform: terraform init, terraform validate, terraform plan, terraform fmt -- Helm: helm lint, helm template, helm dependency -- Kubernetes: kubectl apply --dry-run, kubectl diff -- Build: make, npm run, cargo build, go build -- Linting: hadolint, tflint, yamllint, shellcheck - -Use this to validate generated configurations: -- `docker build -t test .` - Validate Dockerfile -- `terraform validate` - Validate Terraform configuration -- `helm lint ./chart` - Validate Helm chart -- `hadolint Dockerfile` - Lint Dockerfile"#.to_string(), + description: r#"Execute shell commands for building and validation. RESTRICTED to commands that CANNOT be done with native tools. + +**DO NOT use shell for linting - use NATIVE tools instead:** +- Dockerfile linting → use `hadolint` tool (NOT shell hadolint) +- docker-compose linting → use `dclint` tool (NOT shell docker-compose config) +- Helm chart linting → use `helmlint` tool (NOT shell helm lint) +- Kubernetes YAML linting → use `kubelint` tool (NOT shell kubectl/kubeval) + +**Use shell ONLY for:** +- `docker build` - Actually building Docker images +- `terraform init/validate/plan` - Terraform workflows +- `make`, `npm run`, `cargo build` - Build commands +- `git` commands - Version control operations + +The native linting tools return AI-optimized JSON with priorities and fix recommendations. +Shell linting produces plain text that's harder to parse and act on."#.to_string(), parameters: json!({ "type": "object", "properties": { diff --git a/src/agent/ui/diff.rs b/src/agent/ui/diff.rs index 15321154..3aa0bf18 100644 --- a/src/agent/ui/diff.rs +++ b/src/agent/ui/diff.rs @@ -298,7 +298,7 @@ pub async fn confirm_file_write_with_ide( // Spawn terminal input on blocking thread let path_owned = path.to_string(); - let ide_name = client.ide_name().unwrap_or("IDE").to_string(); + let _ide_name = client.ide_name().unwrap_or("IDE").to_string(); let terminal_handle = tokio::task::spawn_blocking(move || { let options = vec![ "Yes, allow once".to_string(), @@ -306,11 +306,6 @@ pub async fn confirm_file_write_with_ide( "Type here to suggest changes".to_string(), ]; - println!( - "{} Diff opened in {} - respond here or in the IDE", - "→".cyan(), - ide_name - ); println!("{}", "Apply this change?".white()); // Signal that the menu is about to be displayed diff --git a/src/agent/ui/helmlint_display.rs b/src/agent/ui/helmlint_display.rs index 91e90ad6..aba43dd0 100644 --- a/src/agent/ui/helmlint_display.rs +++ b/src/agent/ui/helmlint_display.rs @@ -57,12 +57,7 @@ impl HelmlintDisplay { ); // Empty line - let _ = writeln!( - handle, - "{}│{}", - brand::DIM, - " ".repeat(BOX_WIDTH - 1) - ); + let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1)); // Decision context if let Some(context) = result["decision_context"].as_str() { @@ -95,12 +90,7 @@ impl HelmlintDisplay { } // Empty line - let _ = writeln!( - handle, - "{}│{}", - brand::DIM, - " ".repeat(BOX_WIDTH - 1) - ); + let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1)); // Summary counts if let Some(summary) = result.get("summary") { @@ -120,12 +110,7 @@ impl HelmlintDisplay { // Files checked let files = summary["files_checked"].as_u64().unwrap_or(0); let stats = format!("{} files checked", files); - let _ = writeln!( - handle, - "{}│{}", - brand::DIM, - " ".repeat(BOX_WIDTH - 1) - ); + let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1)); let _ = writeln!( handle, "{}│ {}{}{}{}", @@ -178,12 +163,7 @@ impl HelmlintDisplay { if let Some(quick_fixes) = result.get("quick_fixes").and_then(|f| f.as_array()) && !quick_fixes.is_empty() { - let _ = writeln!( - handle, - "{}│{}", - brand::DIM, - " ".repeat(BOX_WIDTH - 1) - ); + let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1)); let _ = writeln!( handle, "{}│ {}{} Quick Fixes:{}{}", @@ -241,7 +221,13 @@ impl HelmlintDisplay { } // Critical and High priority issues with details - Self::print_priority_section(&mut handle, result, "critical", "Critical Issues", brand::CORAL); + Self::print_priority_section( + &mut handle, + result, + "critical", + "Critical Issues", + brand::CORAL, + ); Self::print_priority_section(&mut handle, result, "high", "High Priority", brand::PEACH); // Medium/Low summary @@ -256,16 +242,15 @@ impl HelmlintDisplay { let other_count = medium_count + low_count; if other_count > 0 { - let _ = writeln!( - handle, - "{}│{}", - brand::DIM, - " ".repeat(BOX_WIDTH - 1) - ); + let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1)); let msg = format!( "{} {} priority issue{} (use --verbose to see all)", other_count, - if medium_count > 0 { "medium/low" } else { "low" }, + if medium_count > 0 { + "medium/low" + } else { + "low" + }, if other_count == 1 { "" } else { "s" } ); let _ = writeln!( @@ -305,12 +290,7 @@ impl HelmlintDisplay { return; } - let _ = writeln!( - handle, - "{}│{}", - brand::DIM, - " ".repeat(BOX_WIDTH - 1) - ); + let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1)); let _ = writeln!( handle, "{}│ {}{}:{}{}", diff --git a/src/agent/ui/hooks.rs b/src/agent/ui/hooks.rs index a49a42ed..8618c441 100644 --- a/src/agent/ui/hooks.rs +++ b/src/agent/ui/hooks.rs @@ -55,7 +55,7 @@ impl AccumulatedUsage { } /// Shared state for the display -#[derive(Debug, Default)] +#[derive(Default)] pub struct DisplayState { pub tool_calls: Vec, pub agent_messages: Vec, @@ -63,6 +63,10 @@ pub struct DisplayState { pub last_expandable_index: Option, /// Accumulated token usage from API responses pub usage: AccumulatedUsage, + /// Optional progress indicator state for real-time token display + pub progress_state: Option>, + /// Cancel signal from rig - stored for external cancellation trigger + pub cancel_signal: Option, } /// A hook that shows Claude Code style tool execution @@ -94,6 +98,36 @@ impl ToolDisplayHook { let mut state = self.state.lock().await; state.usage = AccumulatedUsage::default(); } + + /// Set the progress indicator state for real-time token display + pub async fn set_progress_state( + &self, + progress: std::sync::Arc, + ) { + let mut state = self.state.lock().await; + state.progress_state = Some(progress); + } + + /// Clear the progress state + pub async fn clear_progress_state(&self) { + let mut state = self.state.lock().await; + state.progress_state = None; + } + + /// Trigger cancellation of the current request. + /// This will cause rig to stop after the current tool/response completes. + pub async fn cancel(&self) { + let state = self.state.lock().await; + if let Some(ref cancel_sig) = state.cancel_signal { + cancel_sig.cancel(); + } + } + + /// Check if cancellation is possible (a cancel signal is stored) + pub async fn can_cancel(&self) -> bool { + let state = self.state.lock().await; + state.cancel_signal.is_some() + } } impl Default for ToolDisplayHook { @@ -111,16 +145,51 @@ where tool_name: &str, _tool_call_id: Option, args: &str, - _cancel: CancelSignal, + cancel: CancelSignal, ) -> impl std::future::Future + Send { let state = self.state.clone(); let name = tool_name.to_string(); let args_str = args.to_string(); async move { - // Print tool header + // Store the cancel signal for external cancellation + { + let mut s = state.lock().await; + s.cancel_signal = Some(cancel); + } + // Pause progress indicator before printing + { + let s = state.lock().await; + if let Some(ref progress) = s.progress_state { + progress.pause(); + } + } + + // Clear any progress line that might still be visible (timing issue) + // Progress loop will also clear, but we do it here to avoid race + print!("\r{}", ansi::CLEAR_LINE); + let _ = io::stdout().flush(); + + // Print tool header with spacing + println!(); // Add blank line before tool output print_tool_header(&name, &args_str); + // Update progress indicator with current action (for when it resumes) + { + let s = state.lock().await; + if let Some(ref progress) = s.progress_state { + // Set action based on tool type + let action = tool_to_action(&name); + progress.set_action(&action); + + // Set focus to tool details + let focus = tool_to_focus(&name, &args_str); + if let Some(f) = focus { + progress.set_focus(&f); + } + } + } + // Store in state let mut s = state.lock().await; let idx = s.tool_calls.len(); @@ -172,6 +241,13 @@ where } } s.current_tool_index = None; + + // Resume progress indicator after tool completes + if let Some(ref progress) = s.progress_state { + progress.set_action("Thinking"); + progress.clear_focus(); + progress.resume(); + } } } @@ -179,13 +255,17 @@ where &self, _prompt: &Message, response: &CompletionResponse, - _cancel: CancelSignal, + cancel: CancelSignal, ) -> impl std::future::Future + Send { let state = self.state.clone(); // Capture usage from response for token tracking let usage = response.usage; + // Store the cancel signal immediately - this is called before tool calls + // so we can support Ctrl+C during initial "Thinking" phase + let cancel_for_store = cancel.clone(); + // Check if response contains tool calls - if so, any text is "thinking" // If no tool calls, this is the final response - don't show as thinking let has_tool_calls = response @@ -232,23 +312,47 @@ where .collect(); async move { + // Store the cancel signal first - enables Ctrl+C during initial "Thinking" + { + let mut s = state.lock().await; + s.cancel_signal = Some(cancel_for_store); + } + // Accumulate usage tokens from this response { let mut s = state.lock().await; s.usage.add(&usage); + + // Update progress indicator if connected + if let Some(ref progress) = s.progress_state { + progress.update_tokens(usage.input_tokens, usage.output_tokens); + } } // First, show reasoning content if available (GPT-5.2 thinking) if !reasoning_parts.is_empty() { let thinking_text = reasoning_parts.join("\n"); - // Store in state for history tracking + // Store in state for history tracking and pause progress let mut s = state.lock().await; s.agent_messages.push(thinking_text.clone()); + if let Some(ref progress) = s.progress_state { + progress.pause(); + } drop(s); - // Display reasoning as thinking + // Clear any progress line (race condition prevention) + print!("\r{}", ansi::CLEAR_LINE); + let _ = io::stdout().flush(); + + // Display reasoning as thinking (minimal style - no redundant header) print_agent_thinking(&thinking_text); + + // Resume progress after + let s = state.lock().await; + if let Some(ref progress) = s.progress_state { + progress.resume(); + } } // Also show text content if it's intermediate (has tool calls) @@ -256,33 +360,39 @@ where if !text_parts.is_empty() && has_tool_calls { let thinking_text = text_parts.join("\n"); - // Store in state for history tracking + // Store in state for history tracking and pause progress let mut s = state.lock().await; s.agent_messages.push(thinking_text.clone()); + if let Some(ref progress) = s.progress_state { + progress.pause(); + } drop(s); - // Display as thinking + // Clear any progress line (race condition prevention) + print!("\r{}", ansi::CLEAR_LINE); + let _ = io::stdout().flush(); + + // Display as thinking (minimal style) print_agent_thinking(&thinking_text); + + // Resume progress after + let s = state.lock().await; + if let Some(ref progress) = s.progress_state { + progress.resume(); + } } } } } /// Print agent thinking/reasoning text with nice formatting +/// Note: No header needed - progress indicator shows "Thinking" action fn print_agent_thinking(text: &str) { use crate::agent::ui::response::brand; println!(); - // Print thinking header in peach/coral - println!( - "{}{} 💭 Thinking...{}", - brand::CORAL, - brand::ITALIC, - brand::RESET - ); - - // Format the content with markdown support + // Format the content with markdown support (subtle style) let mut in_code_block = false; for line in text.lines() { @@ -1099,11 +1209,26 @@ fn format_kubelint_result( } else { err_str.to_string() }; - lines.push(format!("{} {} {}{}", ansi::HIGH, if i == errors.len().min(3) - 1 { "└" } else { "│" }, truncated, ansi::RESET)); + lines.push(format!( + "{} {} {}{}", + ansi::HIGH, + if i == errors.len().min(3) - 1 { + "└" + } else { + "│" + }, + truncated, + ansi::RESET + )); } } if errors.len() > 3 { - lines.push(format!("{} +{} more errors{}", ansi::GRAY, errors.len() - 3, ansi::RESET)); + lines.push(format!( + "{} +{} more errors{}", + ansi::GRAY, + errors.len() - 3, + ansi::RESET + )); } // If we only have parse errors and no lint issues, return early if total == 0 { @@ -1146,13 +1271,23 @@ fn format_kubelint_result( // Summary with priority breakdown let mut priority_parts = Vec::new(); if critical > 0 { - priority_parts.push(format!("{}🔴 {} critical{}", ansi::CRITICAL, critical, ansi::RESET)); + priority_parts.push(format!( + "{}🔴 {} critical{}", + ansi::CRITICAL, + critical, + ansi::RESET + )); } if high > 0 { priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET)); } if medium > 0 { - priority_parts.push(format!("{}🟡 {} medium{}", ansi::MEDIUM, medium, ansi::RESET)); + priority_parts.push(format!( + "{}🟡 {} medium{}", + ansi::MEDIUM, + medium, + ansi::RESET + )); } if low > 0 { priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET)); @@ -1211,7 +1346,12 @@ fn format_kubelint_result( } else { first_fix.to_string() }; - lines.push(format!("{} → Fix: {}{}", ansi::INFO_BLUE, truncated, ansi::RESET)); + lines.push(format!( + "{} → Fix: {}{}", + ansi::INFO_BLUE, + truncated, + ansi::RESET + )); } } @@ -1258,9 +1398,16 @@ fn format_kubelint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> format!( "{}{} L{}:{} {}{}[{}]{} {} {}", - color, icon, line_num, ansi::RESET, - ansi::CYAN, ansi::BOLD, check, ansi::RESET, - badge, msg_display + color, + icon, + line_num, + ansi::RESET, + ansi::CYAN, + ansi::BOLD, + check, + ansi::RESET, + badge, + msg_display ) } @@ -1298,11 +1445,26 @@ fn format_helmlint_result( } else { err_str.to_string() }; - lines.push(format!("{} {} {}{}", ansi::HIGH, if i == errors.len().min(3) - 1 { "└" } else { "│" }, truncated, ansi::RESET)); + lines.push(format!( + "{} {} {}{}", + ansi::HIGH, + if i == errors.len().min(3) - 1 { + "└" + } else { + "│" + }, + truncated, + ansi::RESET + )); } } if errors.len() > 3 { - lines.push(format!("{} +{} more errors{}", ansi::GRAY, errors.len() - 3, ansi::RESET)); + lines.push(format!( + "{} +{} more errors{}", + ansi::GRAY, + errors.len() - 3, + ansi::RESET + )); } // If we only have parse errors and no lint issues, return early if total == 0 { @@ -1345,13 +1507,23 @@ fn format_helmlint_result( // Summary with priority breakdown let mut priority_parts = Vec::new(); if critical > 0 { - priority_parts.push(format!("{}🔴 {} critical{}", ansi::CRITICAL, critical, ansi::RESET)); + priority_parts.push(format!( + "{}🔴 {} critical{}", + ansi::CRITICAL, + critical, + ansi::RESET + )); } if high > 0 { priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET)); } if medium > 0 { - priority_parts.push(format!("{}🟡 {} medium{}", ansi::MEDIUM, medium, ansi::RESET)); + priority_parts.push(format!( + "{}🟡 {} medium{}", + ansi::MEDIUM, + medium, + ansi::RESET + )); } if low > 0 { priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET)); @@ -1410,7 +1582,12 @@ fn format_helmlint_result( } else { first_fix.to_string() }; - lines.push(format!("{} → Fix: {}{}", ansi::INFO_BLUE, truncated, ansi::RESET)); + lines.push(format!( + "{} → Fix: {}{}", + ansi::INFO_BLUE, + truncated, + ansi::RESET + )); } } @@ -1465,12 +1642,89 @@ fn format_helmlint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> format!( "{}{} {}:{}:{} {}{}[{}]{} {} {}", - color, icon, file_short, line_num, ansi::RESET, - ansi::CYAN, ansi::BOLD, code, ansi::RESET, - badge, msg_display + color, + icon, + file_short, + line_num, + ansi::RESET, + ansi::CYAN, + ansi::BOLD, + code, + ansi::RESET, + badge, + msg_display ) } +/// Convert tool name to a friendly action description for progress indicator +fn tool_to_action(tool_name: &str) -> String { + match tool_name { + "read_file" => "Reading file".to_string(), + "write_file" | "write_files" => "Writing file".to_string(), + "list_directory" => "Listing directory".to_string(), + "shell" => "Running command".to_string(), + "analyze_project" => "Analyzing project".to_string(), + "security_scan" | "check_vulnerabilities" => "Scanning security".to_string(), + "hadolint" => "Linting Dockerfile".to_string(), + "dclint" => "Linting docker-compose".to_string(), + "kubelint" => "Linting Kubernetes".to_string(), + "helmlint" => "Linting Helm chart".to_string(), + "terraform_fmt" => "Formatting Terraform".to_string(), + "terraform_validate" => "Validating Terraform".to_string(), + "plan_create" => "Creating plan".to_string(), + "plan_list" => "Listing plans".to_string(), + "plan_next" | "plan_update" => "Updating plan".to_string(), + _ => "Processing".to_string(), + } +} + +/// Extract focus/detail from tool arguments for progress indicator +fn tool_to_focus(tool_name: &str, args: &str) -> Option { + let parsed: Result = serde_json::from_str(args); + let parsed = parsed.ok()?; + + match tool_name { + "read_file" | "write_file" => { + parsed.get("path").and_then(|p| p.as_str()).map(|p| { + // Shorten long paths + if p.len() > 50 { + format!("...{}", &p[p.len().saturating_sub(47)..]) + } else { + p.to_string() + } + }) + } + "list_directory" => parsed + .get("path") + .and_then(|p| p.as_str()) + .map(|p| p.to_string()), + "shell" => parsed.get("command").and_then(|c| c.as_str()).map(|cmd| { + // Truncate long commands + if cmd.len() > 60 { + format!("{}...", &cmd[..57]) + } else { + cmd.to_string() + } + }), + "hadolint" | "dclint" | "kubelint" | "helmlint" => parsed + .get("path") + .and_then(|p| p.as_str()) + .map(|p| p.to_string()) + .or_else(|| { + if parsed.get("content").is_some() { + Some("".to_string()) + } else { + Some("".to_string()) + } + }), + "plan_create" => parsed + .get("name") + .and_then(|n| n.as_str()) + .map(|n| n.to_string()), + _ => None, + } +} + // Legacy exports for compatibility pub use crate::agent::ui::Spinner; use tokio::sync::mpsc; diff --git a/src/agent/ui/input.rs b/src/agent/ui/input.rs index d53c8af7..228cc108 100644 --- a/src/agent/ui/input.rs +++ b/src/agent/ui/input.rs @@ -684,6 +684,10 @@ pub fn read_input_with_file_picker( ) -> InputResult { let mut stdout = io::stdout(); + // Always ensure cursor is visible at start of input (may have been hidden by progress indicator) + print!("{}", ansi::SHOW_CURSOR); + let _ = stdout.flush(); + // Enable raw mode if terminal::enable_raw_mode().is_err() { return read_simple_input(prompt); diff --git a/src/agent/ui/kubelint_display.rs b/src/agent/ui/kubelint_display.rs index c20f4db2..58ae152c 100644 --- a/src/agent/ui/kubelint_display.rs +++ b/src/agent/ui/kubelint_display.rs @@ -57,12 +57,7 @@ impl KubelintDisplay { ); // Empty line - let _ = writeln!( - handle, - "{}│{}", - brand::DIM, - " ".repeat(BOX_WIDTH - 1) - ); + let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1)); // Decision context if let Some(context) = result["decision_context"].as_str() { @@ -95,12 +90,7 @@ impl KubelintDisplay { } // Empty line - let _ = writeln!( - handle, - "{}│{}", - brand::DIM, - " ".repeat(BOX_WIDTH - 1) - ); + let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1)); // Summary counts if let Some(summary) = result.get("summary") { @@ -121,12 +111,7 @@ impl KubelintDisplay { let objects = summary["objects_analyzed"].as_u64().unwrap_or(0); let checks = summary["checks_run"].as_u64().unwrap_or(0); let stats = format!("{} objects analyzed • {} checks run", objects, checks); - let _ = writeln!( - handle, - "{}│{}", - brand::DIM, - " ".repeat(BOX_WIDTH - 1) - ); + let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1)); let _ = writeln!( handle, "{}│ {}{}{}{}", @@ -179,12 +164,7 @@ impl KubelintDisplay { if let Some(quick_fixes) = result.get("quick_fixes").and_then(|f| f.as_array()) && !quick_fixes.is_empty() { - let _ = writeln!( - handle, - "{}│{}", - brand::DIM, - " ".repeat(BOX_WIDTH - 1) - ); + let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1)); let _ = writeln!( handle, "{}│ {}{} Quick Fixes:{}{}", @@ -242,7 +222,13 @@ impl KubelintDisplay { } // Critical and High priority issues with details - Self::print_priority_section(&mut handle, result, "critical", "Critical Issues", brand::CORAL); + Self::print_priority_section( + &mut handle, + result, + "critical", + "Critical Issues", + brand::CORAL, + ); Self::print_priority_section(&mut handle, result, "high", "High Priority", brand::PEACH); // Medium/Low summary @@ -257,16 +243,15 @@ impl KubelintDisplay { let other_count = medium_count + low_count; if other_count > 0 { - let _ = writeln!( - handle, - "{}│{}", - brand::DIM, - " ".repeat(BOX_WIDTH - 1) - ); + let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1)); let msg = format!( "{} {} priority issue{} (use --verbose to see all)", other_count, - if medium_count > 0 { "medium/low" } else { "low" }, + if medium_count > 0 { + "medium/low" + } else { + "low" + }, if other_count == 1 { "" } else { "s" } ); let _ = writeln!( @@ -306,12 +291,7 @@ impl KubelintDisplay { return; } - let _ = writeln!( - handle, - "{}│{}", - brand::DIM, - " ".repeat(BOX_WIDTH - 1) - ); + let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1)); let _ = writeln!( handle, "{}│ {}{}:{}{}", diff --git a/src/agent/ui/layout.rs b/src/agent/ui/layout.rs new file mode 100644 index 00000000..63f10d61 --- /dev/null +++ b/src/agent/ui/layout.rs @@ -0,0 +1,397 @@ +//! Terminal layout with ANSI scrolling regions +//! +//! Provides a split terminal layout: +//! - Scrollable content area (top) - for tool output, thinking, responses +//! - Fixed status line - for progress indicator +//! - Fixed input line - always visible prompt +//! +//! Uses ANSI escape codes for scroll regions, compatible with most terminals. + +use crossterm::{cursor::MoveTo, execute, terminal}; +use std::io::{self, Write}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; + +use super::colors::ansi; + +/// Number of lines reserved at bottom (status + focus + input + mode indicator) +const RESERVED_LINES: u16 = 4; + +/// ANSI escape codes for scroll region control +pub mod escape { + /// Set scroll region from line `top` to line `bottom` (1-indexed) + pub fn set_scroll_region(top: u16, bottom: u16) -> String { + format!("\x1b[{};{}r", top, bottom) + } + + /// Reset scroll region to full screen + pub const RESET_SCROLL_REGION: &str = "\x1b[r"; + + /// Save cursor position + pub const SAVE_CURSOR: &str = "\x1b[s"; + + /// Restore cursor position + pub const RESTORE_CURSOR: &str = "\x1b[u"; + + /// Move cursor to line (1-indexed), column 1 + pub fn move_to_line(line: u16) -> String { + format!("\x1b[{};1H", line) + } +} + +/// Shared state for terminal layout +#[derive(Debug)] +pub struct LayoutState { + /// Whether layout is active + pub active: AtomicBool, + /// Terminal height when layout was set up + pub term_height: AtomicU16, + /// Terminal width + pub term_width: AtomicU16, +} + +impl Default for LayoutState { + fn default() -> Self { + let (width, height) = terminal::size().unwrap_or((80, 24)); + Self { + active: AtomicBool::new(false), + term_height: AtomicU16::new(height), + term_width: AtomicU16::new(width), + } + } +} + +impl LayoutState { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + pub fn is_active(&self) -> bool { + self.active.load(Ordering::SeqCst) + } + + pub fn height(&self) -> u16 { + self.term_height.load(Ordering::SeqCst) + } + + pub fn width(&self) -> u16 { + self.term_width.load(Ordering::SeqCst) + } + + /// Get the line number for status (1-indexed) + pub fn status_line(&self) -> u16 { + self.height().saturating_sub(3) + } + + /// Get the line number for focus/detail (1-indexed) + pub fn focus_line(&self) -> u16 { + self.height().saturating_sub(2) + } + + /// Get the line number for input (1-indexed) + pub fn input_line(&self) -> u16 { + self.height().saturating_sub(1) + } + + /// Get the line number for mode indicator (1-indexed) + pub fn mode_line(&self) -> u16 { + self.height() + } +} + +/// Terminal layout manager with scroll regions +pub struct TerminalLayout { + state: Arc, +} + +impl TerminalLayout { + /// Create a new layout manager + pub fn new() -> Self { + Self { + state: LayoutState::new(), + } + } + + /// Get shared state for external access + pub fn state(&self) -> Arc { + self.state.clone() + } + + /// Initialize the layout - sets up scroll region and fixed lines + pub fn init(&self) -> io::Result<()> { + let mut stdout = io::stdout(); + + // Get current terminal size + let (width, height) = terminal::size()?; + self.state.term_width.store(width, Ordering::SeqCst); + self.state.term_height.store(height, Ordering::SeqCst); + + // Calculate scroll region (leave RESERVED_LINES at bottom) + let scroll_bottom = height.saturating_sub(RESERVED_LINES); + + // Move to bottom and create space for reserved lines + execute!(stdout, MoveTo(0, height - 1))?; + for _ in 0..RESERVED_LINES { + println!(); + } + + // Set scroll region (top to scroll_bottom) + print!("{}", escape::set_scroll_region(1, scroll_bottom)); + + // Move cursor to top of scroll region + execute!(stdout, MoveTo(0, 0))?; + + // Draw initial fixed lines (status, focus, input, mode) + self.draw_status_line("")?; + self.draw_focus_line(None)?; + self.draw_input_line(false)?; + self.draw_mode_line(false)?; + + // Move back to scroll region + execute!(stdout, MoveTo(0, 0))?; + + self.state.active.store(true, Ordering::SeqCst); + stdout.flush()?; + + Ok(()) + } + + /// Update the status line (progress indicator area) + pub fn update_status(&self, content: &str) -> io::Result<()> { + if !self.state.is_active() { + return Ok(()); + } + + let mut stdout = io::stdout(); + let status_line = self.state.status_line(); + + // Save cursor, move to status line, clear and print, restore + print!("{}", escape::SAVE_CURSOR); + print!("{}", escape::move_to_line(status_line)); + print!("{}", ansi::CLEAR_LINE); + print!("{}", content); + print!("{}", escape::RESTORE_CURSOR); + stdout.flush()?; + + Ok(()) + } + + /// Draw the status line with optional content + fn draw_status_line(&self, content: &str) -> io::Result<()> { + let mut stdout = io::stdout(); + let status_line = self.state.status_line(); + + print!("{}", escape::move_to_line(status_line)); + print!("{}", ansi::CLEAR_LINE); + if !content.is_empty() { + print!("{}", content); + } + stdout.flush()?; + + Ok(()) + } + + /// Draw the focus/detail line + fn draw_focus_line(&self, content: Option<&str>) -> io::Result<()> { + let mut stdout = io::stdout(); + let focus_line = self.state.focus_line(); + + print!("{}", escape::move_to_line(focus_line)); + print!("{}", ansi::CLEAR_LINE); + if let Some(text) = content { + print!( + "{}└{} {}{}{}", + ansi::DIM, + ansi::RESET, + ansi::GRAY, + text, + ansi::RESET + ); + } + stdout.flush()?; + + Ok(()) + } + + /// Draw the input line + fn draw_input_line(&self, _has_text: bool) -> io::Result<()> { + let mut stdout = io::stdout(); + let input_line = self.state.input_line(); + + print!("{}", escape::move_to_line(input_line)); + print!("{}", ansi::CLEAR_LINE); + // Input prompt will be drawn by input handler + stdout.flush()?; + + Ok(()) + } + + /// Draw the mode indicator line + fn draw_mode_line(&self, plan_mode: bool) -> io::Result<()> { + let mut stdout = io::stdout(); + let mode_line = self.state.mode_line(); + + print!("{}", escape::move_to_line(mode_line)); + print!("{}", ansi::CLEAR_LINE); + + if plan_mode { + print!( + "{}⏸ plan mode on (shift+tab to switch){}", + ansi::DIM, + ansi::RESET + ); + } else { + print!( + "{}▶ standard mode (shift+tab to switch){}", + ansi::DIM, + ansi::RESET + ); + } + stdout.flush()?; + + Ok(()) + } + + /// Update the mode indicator + pub fn update_mode(&self, plan_mode: bool) -> io::Result<()> { + if !self.state.is_active() { + return Ok(()); + } + + let mut stdout = io::stdout(); + + print!("{}", escape::SAVE_CURSOR); + self.draw_mode_line(plan_mode)?; + print!("{}", escape::RESTORE_CURSOR); + stdout.flush()?; + + Ok(()) + } + + /// Position cursor at the input line for user input + pub fn position_for_input(&self) -> io::Result<()> { + if !self.state.is_active() { + return Ok(()); + } + + let mut stdout = io::stdout(); + let input_line = self.state.input_line(); + + print!("{}", escape::move_to_line(input_line)); + print!("{}", ansi::CLEAR_LINE); + stdout.flush()?; + + Ok(()) + } + + /// Return cursor to scroll region (for output) + pub fn position_for_output(&self) -> io::Result<()> { + if !self.state.is_active() { + return Ok(()); + } + + // Restore saved cursor position (in scroll region) + print!("{}", escape::RESTORE_CURSOR); + io::stdout().flush()?; + + Ok(()) + } + + /// Clean up - reset scroll region and restore terminal + pub fn cleanup(&self) -> io::Result<()> { + if !self.state.is_active() { + return Ok(()); + } + + let mut stdout = io::stdout(); + + // Reset scroll region + print!("{}", escape::RESET_SCROLL_REGION); + + // Clear the fixed lines + let height = self.state.height(); + for line in (height - RESERVED_LINES + 1)..=height { + print!("{}", escape::move_to_line(line)); + print!("{}", ansi::CLEAR_LINE); + } + + // Move to bottom + execute!(stdout, MoveTo(0, height - 1))?; + print!("{}", ansi::SHOW_CURSOR); + + self.state.active.store(false, Ordering::SeqCst); + stdout.flush()?; + + Ok(()) + } + + /// Handle terminal resize + pub fn handle_resize(&self) -> io::Result<()> { + if !self.state.is_active() { + return Ok(()); + } + + // Get new size + let (width, height) = terminal::size()?; + self.state.term_width.store(width, Ordering::SeqCst); + self.state.term_height.store(height, Ordering::SeqCst); + + // Recalculate and set new scroll region + let scroll_bottom = height.saturating_sub(RESERVED_LINES); + print!("{}", escape::set_scroll_region(1, scroll_bottom)); + + // Redraw fixed lines + self.draw_status_line("")?; + self.draw_focus_line(None)?; + self.draw_input_line(false)?; + self.draw_mode_line(false)?; + + io::stdout().flush()?; + Ok(()) + } +} + +impl Default for TerminalLayout { + fn default() -> Self { + Self::new() + } +} + +impl Drop for TerminalLayout { + fn drop(&mut self) { + let _ = self.cleanup(); + } +} + +/// Print content to the scroll region (normal output area) +/// This ensures output goes to the right place when layout is active +pub fn print_to_scroll_region(content: &str) { + // Just print normally - the scroll region handles it + print!("{}", content); + let _ = io::stdout().flush(); +} + +/// Println to the scroll region +pub fn println_to_scroll_region(content: &str) { + println!("{}", content); + let _ = io::stdout().flush(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_layout_state_defaults() { + let state = LayoutState::default(); + assert!(!state.is_active()); + assert!(state.height() > 0); + assert!(state.width() > 0); + } + + #[test] + fn test_scroll_region_escape() { + assert_eq!(escape::set_scroll_region(1, 20), "\x1b[1;20r"); + assert_eq!(escape::move_to_line(5), "\x1b[5;1H"); + } +} diff --git a/src/agent/ui/mod.rs b/src/agent/ui/mod.rs index c632fe54..a5fd6e0e 100644 --- a/src/agent/ui/mod.rs +++ b/src/agent/ui/mod.rs @@ -4,11 +4,12 @@ //! - Beautiful response formatting with markdown rendering //! - Real-time streaming response display //! - Visible tool call execution with status indicators -//! - Animated spinners with witty phrases during processing +//! - Animated progress bar with token counter during generation //! - Thinking/reasoning indicators //! - Elapsed time tracking //! - Interactive tool confirmation prompts //! - Diff rendering for file changes +//! - ANSI scroll regions for split layout (output + fixed input) pub mod autocomplete; pub mod colors; @@ -19,7 +20,9 @@ pub mod helmlint_display; pub mod hooks; pub mod input; pub mod kubelint_display; +pub mod layout; pub mod plan_menu; +pub mod progress; pub mod response; pub mod shell_output; pub mod spinner; @@ -35,7 +38,9 @@ pub use helmlint_display::*; pub use hooks::*; pub use input::*; pub use kubelint_display::*; +pub use layout::*; pub use plan_menu::*; +pub use progress::*; pub use response::*; pub use shell_output::*; pub use spinner::*; diff --git a/src/agent/ui/progress.rs b/src/agent/ui/progress.rs new file mode 100644 index 00000000..97b64a0e --- /dev/null +++ b/src/agent/ui/progress.rs @@ -0,0 +1,526 @@ +//! Generation progress indicator - Claude Code style +//! +//! Shows a clean status line with current action during AI response generation. +//! Format: ✱ Action… (esc to interrupt) +//! +//! Inspired by Claude Code's elegant minimal approach. + +use crate::agent::ui::colors::ansi; +use parking_lot::RwLock; +use std::io::{self, Write}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +/// Animation frames for the indicator asterisk (subtle pulse) +const INDICATOR_FRAMES: &[&str] = &["✱", "✳", "✱", "✴", "✱", "✳"]; + +/// Animation interval - slower for subtle effect +const ANIMATION_INTERVAL_MS: u64 = 300; + +/// Messages for controlling the progress indicator +#[derive(Debug, Clone)] +pub enum ProgressMessage { + /// Update token counts (input, output) + UpdateTokens { input: u64, output: u64 }, + /// Update the current action being performed + Action(String), + /// Update the detail/focus text (shown below main line) + Focus(String), + /// Clear the focus text + ClearFocus, + /// Stop the indicator + Stop, +} + +/// Shared state for progress tracking +#[derive(Debug)] +pub struct ProgressState { + pub input_tokens: AtomicU64, + pub output_tokens: AtomicU64, + pub is_running: AtomicBool, + /// Whether the indicator is paused (for coordinating with other output) + pub is_paused: AtomicBool, + /// Whether an interrupt has been requested (ESC pressed) + pub interrupt_requested: AtomicBool, + /// Current action being performed (e.g., "Generating response") + pub action: RwLock, + /// Current focus/detail (e.g., "Reading config.yaml") + pub focus: RwLock>, + /// Start time for elapsed tracking + pub start_time: std::time::Instant, + /// Optional layout state for fixed status line rendering + pub layout_state: RwLock>>, +} + +impl Default for ProgressState { + fn default() -> Self { + Self { + input_tokens: AtomicU64::new(0), + output_tokens: AtomicU64::new(0), + is_running: AtomicBool::new(true), + is_paused: AtomicBool::new(false), + interrupt_requested: AtomicBool::new(false), + action: RwLock::new("Generating".to_string()), + focus: RwLock::new(None), + start_time: std::time::Instant::now(), + layout_state: RwLock::new(None), + } + } +} + +impl ProgressState { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + pub fn update_tokens(&self, input: u64, output: u64) { + self.input_tokens.fetch_add(input, Ordering::SeqCst); + self.output_tokens.fetch_add(output, Ordering::SeqCst); + } + + pub fn get_tokens(&self) -> (u64, u64) { + ( + self.input_tokens.load(Ordering::SeqCst), + self.output_tokens.load(Ordering::SeqCst), + ) + } + + pub fn set_action(&self, action: &str) { + *self.action.write() = action.to_string(); + } + + pub fn get_action(&self) -> String { + self.action.read().clone() + } + + pub fn set_focus(&self, focus: &str) { + *self.focus.write() = Some(focus.to_string()); + } + + pub fn clear_focus(&self) { + *self.focus.write() = None; + } + + pub fn get_focus(&self) -> Option { + self.focus.read().clone() + } + + pub fn stop(&self) { + self.is_running.store(false, Ordering::SeqCst); + } + + pub fn is_running(&self) -> bool { + self.is_running.load(Ordering::SeqCst) + } + + /// Pause the indicator (stops rendering but keeps state) + pub fn pause(&self) { + self.is_paused.store(true, Ordering::SeqCst); + } + + /// Resume the indicator after pause + pub fn resume(&self) { + self.is_paused.store(false, Ordering::SeqCst); + } + + pub fn is_paused(&self) -> bool { + self.is_paused.load(Ordering::SeqCst) + } + + /// Get elapsed time since start + pub fn elapsed(&self) -> std::time::Duration { + self.start_time.elapsed() + } + + /// Set the layout state for fixed status line rendering + pub fn set_layout(&self, layout: std::sync::Arc) { + *self.layout_state.write() = Some(layout); + } + + /// Check if layout is active (for choosing render mode) + pub fn has_layout(&self) -> bool { + self.layout_state + .read() + .as_ref() + .map(|l| l.is_active()) + .unwrap_or(false) + } + + /// Get layout state if available + pub fn get_layout(&self) -> Option> { + self.layout_state.read().clone() + } + + /// Request an interrupt (called when ESC is pressed) + pub fn request_interrupt(&self) { + self.interrupt_requested.store(true, Ordering::SeqCst); + } + + /// Check if an interrupt has been requested + pub fn is_interrupted(&self) -> bool { + self.interrupt_requested.load(Ordering::SeqCst) + } + + /// Clear the interrupt flag + pub fn clear_interrupt(&self) { + self.interrupt_requested.store(false, Ordering::SeqCst); + } +} + +/// Progress indicator with Claude Code style display +pub struct GenerationIndicator { + sender: mpsc::Sender, + state: Arc, +} + +impl GenerationIndicator { + /// Create and start a new progress indicator + pub fn new() -> Self { + Self::with_action("Generating") + } + + /// Create with a specific initial action + pub fn with_action(action: &str) -> Self { + let (sender, receiver) = mpsc::channel(32); + let state = ProgressState::new(); + state.set_action(action); + let state_clone = state.clone(); + + tokio::spawn(async move { + run_progress_indicator(receiver, state_clone).await; + }); + + Self { sender, state } + } + + /// Update token counts + pub async fn update_tokens(&self, input: u64, output: u64) { + self.state.update_tokens(input, output); + let _ = self + .sender + .send(ProgressMessage::UpdateTokens { input, output }) + .await; + } + + /// Set the current action (e.g., "Analyzing", "Reading files") + pub async fn set_action(&self, action: &str) { + self.state.set_action(action); + let _ = self + .sender + .send(ProgressMessage::Action(action.to_string())) + .await; + } + + /// Set focus/detail text shown below the main status + pub async fn set_focus(&self, focus: &str) { + self.state.set_focus(focus); + let _ = self + .sender + .send(ProgressMessage::Focus(focus.to_string())) + .await; + } + + /// Clear the focus text + pub async fn clear_focus(&self) { + self.state.clear_focus(); + let _ = self.sender.send(ProgressMessage::ClearFocus).await; + } + + /// Stop the indicator + pub async fn stop(&self) { + self.state.stop(); + let _ = self.sender.send(ProgressMessage::Stop).await; + // Give the indicator task time to clean up + tokio::time::sleep(Duration::from_millis(50)).await; + } + + /// Pause the indicator (clears line and shows cursor for other output) + pub async fn pause(&self) { + self.state.pause(); + // Clear current lines to make room for other output + print!("\r{}", ansi::CLEAR_LINE); + print!("{}", ansi::SHOW_CURSOR); + let _ = io::stdout().flush(); + } + + /// Resume the indicator after pause + pub async fn resume(&self) { + self.state.resume(); + print!("{}", ansi::HIDE_CURSOR); + let _ = io::stdout().flush(); + } + + /// Get the shared state for external updates + pub fn state(&self) -> Arc { + self.state.clone() + } +} + +impl Default for GenerationIndicator { + fn default() -> Self { + Self::new() + } +} + +/// Format token count with K suffix for large numbers +fn format_tokens(tokens: u64) -> String { + if tokens >= 100_000 { + format!("{:.1}k", tokens as f64 / 1000.0) + } else if tokens >= 10_000 { + format!("{:.0}k", tokens as f64 / 1000.0) + } else { + tokens.to_string() + } +} + +/// Coral/orange color for the indicator (matches Claude Code) +const CORAL: &str = "\x1b[38;5;209m"; + +/// Internal progress indicator loop - Claude Code style +/// +/// Note: ESC key detection is handled by a separate dedicated listener (spawn_esc_listener) +/// which runs continuously with its own raw mode, independent of this animation loop. +async fn run_progress_indicator( + mut receiver: mpsc::Receiver, + state: Arc, +) { + let start_time = Instant::now(); + let mut frame_index = 0; + let mut had_focus = false; + let mut interval = tokio::time::interval(Duration::from_millis(ANIMATION_INTERVAL_MS)); + + // Hide cursor during animation (only if not using layout) + if !state.has_layout() { + print!("{}", ansi::HIDE_CURSOR); + let _ = io::stdout().flush(); + } + + // Track if we need to clear display on pause + let mut was_rendering = false; + + loop { + tokio::select! { + _ = interval.tick() => { + if !state.is_running() { + break; + } + + let use_layout = state.has_layout(); + + // Handle pause - clear display when transitioning to paused + if state.is_paused() { + if was_rendering && !use_layout { + // Clear our display before yielding to other output (only for non-layout mode) + if had_focus { + print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE); + } + print!("\r{}", ansi::CLEAR_LINE); + print!("{}", ansi::SHOW_CURSOR); + let _ = io::stdout().flush(); + was_rendering = false; + had_focus = false; + } + continue; + } + + // We're about to render - hide cursor if we just resumed + if !was_rendering && !use_layout { + print!("{}", ansi::HIDE_CURSOR); + let _ = io::stdout().flush(); + } + was_rendering = true; + + let elapsed = start_time.elapsed(); + let indicator = INDICATOR_FRAMES[frame_index % INDICATOR_FRAMES.len()]; + frame_index += 1; + + let action = state.get_action(); + let focus = state.get_focus(); + let (input_tokens, output_tokens) = state.get_tokens(); + let total_tokens = input_tokens + output_tokens; + + // Build stats string: (^C to stop · 12.3s · ↓ 28k tokens) + let elapsed_secs = elapsed.as_secs_f64(); + let elapsed_str = if elapsed_secs >= 60.0 { + format!("{:.0}m {:.0}s", elapsed_secs / 60.0, elapsed_secs % 60.0) + } else { + format!("{:.1}s", elapsed_secs) + }; + + let stats = if total_tokens > 0 { + format!( + "{}(^C to stop · {} · ↓ {} tokens){}", + ansi::DIM, + elapsed_str, + format_tokens(total_tokens), + ansi::RESET + ) + } else { + format!( + "{}(^C to stop · {}){}", + ansi::DIM, + elapsed_str, + ansi::RESET + ) + }; + + // Format the status content + let status_content = format!( + "{}{}{} {}{}…{} {}", + CORAL, + indicator, + ansi::RESET, + CORAL, + action, + ansi::RESET, + stats, + ); + + // Render using layout or fallback to inline mode + if use_layout { + if let Some(layout_state) = state.get_layout() { + // Use fixed status line rendering + render_to_layout(&layout_state, &status_content, focus.as_deref()); + } + } else { + // Fallback: inline rendering with \r + // Clear previous lines if we had focus + if had_focus { + print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE); + } + print!("\r{}", ansi::CLEAR_LINE); + + // Main status line + print!("{}", status_content); + + // Focus line below (if set): └ detail + if let Some(ref focus_text) = focus { + print!( + "\n{}└{} {}{}{}", + ansi::DIM, + ansi::RESET, + ansi::GRAY, + focus_text, + ansi::RESET + ); + had_focus = true; + } else { + had_focus = false; + } + + let _ = io::stdout().flush(); + } + } + Some(msg) = receiver.recv() => { + match msg { + ProgressMessage::UpdateTokens { .. } => { + // Handled via shared state + } + ProgressMessage::Action(action) => { + state.set_action(&action); + } + ProgressMessage::Focus(focus) => { + state.set_focus(&focus); + } + ProgressMessage::ClearFocus => { + state.clear_focus(); + } + ProgressMessage::Stop => { + state.stop(); + break; + } + } + } + } + } + + // Clean up - clear the status lines (raw mode is handled by spawn_esc_listener) + if !state.has_layout() { + if had_focus { + print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE); + } + print!("\r{}", ansi::CLEAR_LINE); + print!("{}", ansi::SHOW_CURSOR); + let _ = io::stdout().flush(); + } +} + +/// Render progress to the fixed status line using layout +fn render_to_layout(layout_state: &super::layout::LayoutState, status: &str, focus: Option<&str>) { + use super::layout::escape; + + if !layout_state.is_active() { + return; + } + + let mut stdout = io::stdout(); + let status_line = layout_state.status_line(); + let focus_line = layout_state.focus_line(); + + // Save cursor, move to status line, render + print!("{}", escape::SAVE_CURSOR); + print!("{}", escape::move_to_line(status_line)); + print!("{}", ansi::CLEAR_LINE); + print!("{}", status); + + // Focus on dedicated focus line (not relative \n) + print!("{}", escape::move_to_line(focus_line)); + print!("{}", ansi::CLEAR_LINE); + if let Some(focus_text) = focus { + print!( + "{}└{} {}{}{}", + ansi::DIM, + ansi::RESET, + ansi::GRAY, + focus_text, + ansi::RESET + ); + } + + print!("{}", escape::RESTORE_CURSOR); + let _ = stdout.flush(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_tokens() { + assert_eq!(format_tokens(0), "0"); + assert_eq!(format_tokens(999), "999"); + assert_eq!(format_tokens(1000), "1000"); + assert_eq!(format_tokens(9999), "9999"); + assert_eq!(format_tokens(10000), "10k"); + assert_eq!(format_tokens(10499), "10k"); + assert_eq!(format_tokens(10999), "11k"); + assert_eq!(format_tokens(100000), "100.0k"); + assert_eq!(format_tokens(150000), "150.0k"); + } + + #[test] + fn test_progress_state() { + let state = ProgressState::new(); + assert!(state.is_running()); + assert_eq!(state.get_tokens(), (0, 0)); + assert_eq!(state.get_action(), "Generating"); + assert!(state.get_focus().is_none()); + + state.update_tokens(100, 50); + assert_eq!(state.get_tokens(), (100, 50)); + + state.set_action("Analyzing"); + assert_eq!(state.get_action(), "Analyzing"); + + state.set_focus("Reading file.rs"); + assert_eq!(state.get_focus(), Some("Reading file.rs".to_string())); + + state.clear_focus(); + assert!(state.get_focus().is_none()); + + state.stop(); + assert!(!state.is_running()); + } +} diff --git a/src/agent/ui/response.rs b/src/agent/ui/response.rs index 47cce059..da8798d7 100644 --- a/src/agent/ui/response.rs +++ b/src/agent/ui/response.rs @@ -174,6 +174,7 @@ impl Default for MarkdownFormat { impl MarkdownFormat { /// Create a new MarkdownFormat with Syncable brand colors + #[allow(clippy::field_reassign_with_default)] pub fn new() -> Self { let mut skin = MadSkin::default(); diff --git a/src/analyzer/helmlint/formatter/stylish.rs b/src/analyzer/helmlint/formatter/stylish.rs index 5e50c39b..50330b2b 100644 --- a/src/analyzer/helmlint/formatter/stylish.rs +++ b/src/analyzer/helmlint/formatter/stylish.rs @@ -93,15 +93,14 @@ pub fn format(result: &LintResult) -> String { }; output.push_str(&format!( - " {}{}:{:>8}{} {} {}{}{}", + " {}{}:{:>8}{} {} {} {}", colors::DIM, location, severity_color, severity_text, colors::RESET, failure.message, - colors::DIM, - format!(" {}", failure.code), + failure.code, )); output.push_str(colors::RESET); output.push('\n'); @@ -116,22 +115,24 @@ pub fn format(result: &LintResult) -> String { let infos = total - errors - warnings; if total > 0 { + let status_color = if errors > 0 { + colors::RED + } else { + colors::YELLOW + }; output.push_str(&format!( - "{}{}{}", + "{}{}✖ {} {} ({} {}, {} {}, {} info)\n{}", colors::BOLD, - if errors > 0 { colors::RED } else { colors::YELLOW }, - format!( - "✖ {} {} ({} {}, {} {}, {} info)\n", - total, - if total == 1 { "problem" } else { "problems" }, - errors, - if errors == 1 { "error" } else { "errors" }, - warnings, - if warnings == 1 { "warning" } else { "warnings" }, - infos - ) + status_color, + total, + if total == 1 { "problem" } else { "problems" }, + errors, + if errors == 1 { "error" } else { "errors" }, + warnings, + if warnings == 1 { "warning" } else { "warnings" }, + infos, + colors::RESET, )); - output.push_str(colors::RESET); } output diff --git a/src/analyzer/helmlint/k8s/api_versions.rs b/src/analyzer/helmlint/k8s/api_versions.rs index 7134ddae..0b4a7cc5 100644 --- a/src/analyzer/helmlint/k8s/api_versions.rs +++ b/src/analyzer/helmlint/k8s/api_versions.rs @@ -61,7 +61,10 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ kind: Some("Deployment"), replacement: "apps/v1", deprecated_in: K8sVersion { major: 1, minor: 9 }, - removed_in: K8sVersion { major: 1, minor: 16 }, + removed_in: K8sVersion { + major: 1, + minor: 16, + }, notes: None, }, DeprecatedApi { @@ -69,7 +72,10 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ kind: Some("DaemonSet"), replacement: "apps/v1", deprecated_in: K8sVersion { major: 1, minor: 9 }, - removed_in: K8sVersion { major: 1, minor: 16 }, + removed_in: K8sVersion { + major: 1, + minor: 16, + }, notes: None, }, DeprecatedApi { @@ -77,15 +83,24 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ kind: Some("ReplicaSet"), replacement: "apps/v1", deprecated_in: K8sVersion { major: 1, minor: 9 }, - removed_in: K8sVersion { major: 1, minor: 16 }, + removed_in: K8sVersion { + major: 1, + minor: 16, + }, notes: None, }, DeprecatedApi { api_version: "extensions/v1beta1", kind: Some("Ingress"), replacement: "networking.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 14 }, - removed_in: K8sVersion { major: 1, minor: 22 }, + deprecated_in: K8sVersion { + major: 1, + minor: 14, + }, + removed_in: K8sVersion { + major: 1, + minor: 22, + }, notes: None, }, DeprecatedApi { @@ -93,15 +108,24 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ kind: Some("NetworkPolicy"), replacement: "networking.k8s.io/v1", deprecated_in: K8sVersion { major: 1, minor: 9 }, - removed_in: K8sVersion { major: 1, minor: 16 }, + removed_in: K8sVersion { + major: 1, + minor: 16, + }, notes: None, }, DeprecatedApi { api_version: "extensions/v1beta1", kind: Some("PodSecurityPolicy"), replacement: "policy/v1beta1", - deprecated_in: K8sVersion { major: 1, minor: 10 }, - removed_in: K8sVersion { major: 1, minor: 16 }, + deprecated_in: K8sVersion { + major: 1, + minor: 10, + }, + removed_in: K8sVersion { + major: 1, + minor: 16, + }, notes: Some("PodSecurityPolicy is deprecated entirely in 1.21 and removed in 1.25"), }, // apps/v1beta1 deprecations @@ -110,7 +134,10 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ kind: Some("Deployment"), replacement: "apps/v1", deprecated_in: K8sVersion { major: 1, minor: 9 }, - removed_in: K8sVersion { major: 1, minor: 16 }, + removed_in: K8sVersion { + major: 1, + minor: 16, + }, notes: None, }, DeprecatedApi { @@ -118,7 +145,10 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ kind: Some("StatefulSet"), replacement: "apps/v1", deprecated_in: K8sVersion { major: 1, minor: 9 }, - removed_in: K8sVersion { major: 1, minor: 16 }, + removed_in: K8sVersion { + major: 1, + minor: 16, + }, notes: None, }, // apps/v1beta2 deprecations @@ -127,7 +157,10 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ kind: Some("Deployment"), replacement: "apps/v1", deprecated_in: K8sVersion { major: 1, minor: 9 }, - removed_in: K8sVersion { major: 1, minor: 16 }, + removed_in: K8sVersion { + major: 1, + minor: 16, + }, notes: None, }, DeprecatedApi { @@ -135,7 +168,10 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ kind: Some("DaemonSet"), replacement: "apps/v1", deprecated_in: K8sVersion { major: 1, minor: 9 }, - removed_in: K8sVersion { major: 1, minor: 16 }, + removed_in: K8sVersion { + major: 1, + minor: 16, + }, notes: None, }, DeprecatedApi { @@ -143,7 +179,10 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ kind: Some("ReplicaSet"), replacement: "apps/v1", deprecated_in: K8sVersion { major: 1, minor: 9 }, - removed_in: K8sVersion { major: 1, minor: 16 }, + removed_in: K8sVersion { + major: 1, + minor: 16, + }, notes: None, }, DeprecatedApi { @@ -151,7 +190,10 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ kind: Some("StatefulSet"), replacement: "apps/v1", deprecated_in: K8sVersion { major: 1, minor: 9 }, - removed_in: K8sVersion { major: 1, minor: 16 }, + removed_in: K8sVersion { + major: 1, + minor: 16, + }, notes: None, }, // networking.k8s.io/v1beta1 deprecations @@ -159,16 +201,28 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "networking.k8s.io/v1beta1", kind: Some("Ingress"), replacement: "networking.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 19 }, - removed_in: K8sVersion { major: 1, minor: 22 }, + deprecated_in: K8sVersion { + major: 1, + minor: 19, + }, + removed_in: K8sVersion { + major: 1, + minor: 22, + }, notes: None, }, DeprecatedApi { api_version: "networking.k8s.io/v1beta1", kind: Some("IngressClass"), replacement: "networking.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 19 }, - removed_in: K8sVersion { major: 1, minor: 22 }, + deprecated_in: K8sVersion { + major: 1, + minor: 19, + }, + removed_in: K8sVersion { + major: 1, + minor: 22, + }, notes: None, }, // rbac.authorization.k8s.io/v1beta1 deprecations @@ -176,8 +230,14 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "rbac.authorization.k8s.io/v1beta1", kind: None, replacement: "rbac.authorization.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 17 }, - removed_in: K8sVersion { major: 1, minor: 22 }, + deprecated_in: K8sVersion { + major: 1, + minor: 17, + }, + removed_in: K8sVersion { + major: 1, + minor: 22, + }, notes: Some("Applies to Role, ClusterRole, RoleBinding, ClusterRoleBinding"), }, // admissionregistration.k8s.io/v1beta1 deprecations @@ -185,8 +245,14 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "admissionregistration.k8s.io/v1beta1", kind: None, replacement: "admissionregistration.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 16 }, - removed_in: K8sVersion { major: 1, minor: 22 }, + deprecated_in: K8sVersion { + major: 1, + minor: 16, + }, + removed_in: K8sVersion { + major: 1, + minor: 22, + }, notes: Some("Applies to MutatingWebhookConfiguration, ValidatingWebhookConfiguration"), }, // apiextensions.k8s.io/v1beta1 deprecations @@ -194,8 +260,14 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "apiextensions.k8s.io/v1beta1", kind: Some("CustomResourceDefinition"), replacement: "apiextensions.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 16 }, - removed_in: K8sVersion { major: 1, minor: 22 }, + deprecated_in: K8sVersion { + major: 1, + minor: 16, + }, + removed_in: K8sVersion { + major: 1, + minor: 22, + }, notes: None, }, // policy/v1beta1 deprecations @@ -203,16 +275,28 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "policy/v1beta1", kind: Some("PodDisruptionBudget"), replacement: "policy/v1", - deprecated_in: K8sVersion { major: 1, minor: 21 }, - removed_in: K8sVersion { major: 1, minor: 25 }, + deprecated_in: K8sVersion { + major: 1, + minor: 21, + }, + removed_in: K8sVersion { + major: 1, + minor: 25, + }, notes: None, }, DeprecatedApi { api_version: "policy/v1beta1", kind: Some("PodSecurityPolicy"), replacement: "None (use Pod Security Admission)", - deprecated_in: K8sVersion { major: 1, minor: 21 }, - removed_in: K8sVersion { major: 1, minor: 25 }, + deprecated_in: K8sVersion { + major: 1, + minor: 21, + }, + removed_in: K8sVersion { + major: 1, + minor: 25, + }, notes: Some("PodSecurityPolicy is removed. Use Pod Security Admission instead"), }, // batch/v1beta1 deprecations @@ -220,8 +304,14 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "batch/v1beta1", kind: Some("CronJob"), replacement: "batch/v1", - deprecated_in: K8sVersion { major: 1, minor: 21 }, - removed_in: K8sVersion { major: 1, minor: 25 }, + deprecated_in: K8sVersion { + major: 1, + minor: 21, + }, + removed_in: K8sVersion { + major: 1, + minor: 25, + }, notes: None, }, // certificates.k8s.io/v1beta1 deprecations @@ -229,8 +319,14 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "certificates.k8s.io/v1beta1", kind: Some("CertificateSigningRequest"), replacement: "certificates.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 19 }, - removed_in: K8sVersion { major: 1, minor: 22 }, + deprecated_in: K8sVersion { + major: 1, + minor: 19, + }, + removed_in: K8sVersion { + major: 1, + minor: 22, + }, notes: None, }, // coordination.k8s.io/v1beta1 deprecations @@ -238,8 +334,14 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "coordination.k8s.io/v1beta1", kind: Some("Lease"), replacement: "coordination.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 14 }, - removed_in: K8sVersion { major: 1, minor: 22 }, + deprecated_in: K8sVersion { + major: 1, + minor: 14, + }, + removed_in: K8sVersion { + major: 1, + minor: 22, + }, notes: None, }, // storage.k8s.io/v1beta1 deprecations @@ -247,16 +349,28 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "storage.k8s.io/v1beta1", kind: Some("CSIDriver"), replacement: "storage.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 19 }, - removed_in: K8sVersion { major: 1, minor: 22 }, + deprecated_in: K8sVersion { + major: 1, + minor: 19, + }, + removed_in: K8sVersion { + major: 1, + minor: 22, + }, notes: None, }, DeprecatedApi { api_version: "storage.k8s.io/v1beta1", kind: Some("CSINode"), replacement: "storage.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 17 }, - removed_in: K8sVersion { major: 1, minor: 22 }, + deprecated_in: K8sVersion { + major: 1, + minor: 17, + }, + removed_in: K8sVersion { + major: 1, + minor: 22, + }, notes: None, }, DeprecatedApi { @@ -264,15 +378,24 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ kind: Some("StorageClass"), replacement: "storage.k8s.io/v1", deprecated_in: K8sVersion { major: 1, minor: 6 }, - removed_in: K8sVersion { major: 1, minor: 22 }, + removed_in: K8sVersion { + major: 1, + minor: 22, + }, notes: None, }, DeprecatedApi { api_version: "storage.k8s.io/v1beta1", kind: Some("VolumeAttachment"), replacement: "storage.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 13 }, - removed_in: K8sVersion { major: 1, minor: 22 }, + deprecated_in: K8sVersion { + major: 1, + minor: 13, + }, + removed_in: K8sVersion { + major: 1, + minor: 22, + }, notes: None, }, // scheduling.k8s.io/v1beta1 deprecations @@ -280,8 +403,14 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "scheduling.k8s.io/v1beta1", kind: Some("PriorityClass"), replacement: "scheduling.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 14 }, - removed_in: K8sVersion { major: 1, minor: 22 }, + deprecated_in: K8sVersion { + major: 1, + minor: 14, + }, + removed_in: K8sVersion { + major: 1, + minor: 22, + }, notes: None, }, // discovery.k8s.io/v1beta1 deprecations @@ -289,8 +418,14 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "discovery.k8s.io/v1beta1", kind: Some("EndpointSlice"), replacement: "discovery.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 21 }, - removed_in: K8sVersion { major: 1, minor: 25 }, + deprecated_in: K8sVersion { + major: 1, + minor: 21, + }, + removed_in: K8sVersion { + major: 1, + minor: 25, + }, notes: None, }, // events.k8s.io/v1beta1 deprecations @@ -298,8 +433,14 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "events.k8s.io/v1beta1", kind: Some("Event"), replacement: "events.k8s.io/v1", - deprecated_in: K8sVersion { major: 1, minor: 19 }, - removed_in: K8sVersion { major: 1, minor: 25 }, + deprecated_in: K8sVersion { + major: 1, + minor: 19, + }, + removed_in: K8sVersion { + major: 1, + minor: 25, + }, notes: None, }, // autoscaling/v2beta1 deprecations @@ -307,8 +448,14 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "autoscaling/v2beta1", kind: Some("HorizontalPodAutoscaler"), replacement: "autoscaling/v2", - deprecated_in: K8sVersion { major: 1, minor: 23 }, - removed_in: K8sVersion { major: 1, minor: 26 }, + deprecated_in: K8sVersion { + major: 1, + minor: 23, + }, + removed_in: K8sVersion { + major: 1, + minor: 26, + }, notes: None, }, // autoscaling/v2beta2 deprecations @@ -316,17 +463,23 @@ static DEPRECATED_APIS: &[DeprecatedApi] = &[ api_version: "autoscaling/v2beta2", kind: Some("HorizontalPodAutoscaler"), replacement: "autoscaling/v2", - deprecated_in: K8sVersion { major: 1, minor: 23 }, - removed_in: K8sVersion { major: 1, minor: 26 }, + deprecated_in: K8sVersion { + major: 1, + minor: 23, + }, + removed_in: K8sVersion { + major: 1, + minor: 26, + }, notes: None, }, ]; /// Check if an API version is deprecated for a given kind. pub fn is_api_deprecated(api_version: &str, kind: Option<&str>) -> Option<&'static DeprecatedApi> { - DEPRECATED_APIS.iter().find(|api| { - api.api_version == api_version && (api.kind.is_none() || api.kind == kind) - }) + DEPRECATED_APIS + .iter() + .find(|api| api.api_version == api_version && (api.kind.is_none() || api.kind == kind)) } /// Get the replacement API for a deprecated API. diff --git a/src/analyzer/helmlint/k8s/mod.rs b/src/analyzer/helmlint/k8s/mod.rs index 9f13d906..06235812 100644 --- a/src/analyzer/helmlint/k8s/mod.rs +++ b/src/analyzer/helmlint/k8s/mod.rs @@ -7,4 +7,4 @@ pub mod api_versions; -pub use api_versions::{DeprecatedApi, K8sVersion, is_api_deprecated, get_replacement_api}; +pub use api_versions::{DeprecatedApi, K8sVersion, get_replacement_api, is_api_deprecated}; diff --git a/src/analyzer/helmlint/lint.rs b/src/analyzer/helmlint/lint.rs index 6f46a2e0..ca0cee98 100644 --- a/src/analyzer/helmlint/lint.rs +++ b/src/analyzer/helmlint/lint.rs @@ -8,11 +8,13 @@ use std::path::Path; use crate::analyzer::helmlint::config::HelmlintConfig; use crate::analyzer::helmlint::parser::chart::parse_chart_yaml; -use crate::analyzer::helmlint::parser::helpers::{parse_helpers, ParsedHelpers}; +use crate::analyzer::helmlint::parser::helpers::{ParsedHelpers, parse_helpers}; use crate::analyzer::helmlint::parser::template::parse_template; use crate::analyzer::helmlint::parser::values::parse_values_yaml; -use crate::analyzer::helmlint::pragma::{extract_template_pragmas, extract_yaml_pragmas, PragmaState}; -use crate::analyzer::helmlint::rules::{all_rules, LintContext}; +use crate::analyzer::helmlint::pragma::{ + PragmaState, extract_template_pragmas, extract_yaml_pragmas, +}; +use crate::analyzer::helmlint::rules::{LintContext, all_rules}; use crate::analyzer::helmlint::types::{CheckFailure, Severity}; /// Result of linting a Helm chart. @@ -134,7 +136,9 @@ pub fn lint_chart(path: &Path, config: &HelmlintConfig) -> LintResult { } }, Err(e) => { - result.parse_errors.push(format!("Failed to read Chart.yaml: {}", e)); + result + .parse_errors + .push(format!("Failed to read Chart.yaml: {}", e)); None } } @@ -154,7 +158,9 @@ pub fn lint_chart(path: &Path, config: &HelmlintConfig) -> LintResult { } }, Err(e) => { - result.parse_errors.push(format!("Failed to read values.yaml: {}", e)); + result + .parse_errors + .push(format!("Failed to read values.yaml: {}", e)); None } } @@ -200,10 +206,9 @@ pub fn lint_chart(path: &Path, config: &HelmlintConfig) -> LintResult { templates.push(parsed); } Err(e) => { - result.parse_errors.push(format!( - "Failed to read {}: {}", - relative_path, e - )); + result + .parse_errors + .push(format!("Failed to read {}: {}", relative_path, e)); } } } @@ -280,13 +285,7 @@ pub fn lint_chart(path: &Path, config: &HelmlintConfig) -> LintResult { !all_pragmas.is_ignored(&f.code, f.line) } }) - .filter(|f| { - if config.fixable_only { - f.fixable - } else { - true - } - }) + .filter(|f| if config.fixable_only { f.fixable } else { true }) .map(|mut f| { // Apply severity overrides f.severity = config.effective_severity(f.code.as_str(), f.severity); diff --git a/src/analyzer/helmlint/mod.rs b/src/analyzer/helmlint/mod.rs index 702bb319..0f1d4105 100644 --- a/src/analyzer/helmlint/mod.rs +++ b/src/analyzer/helmlint/mod.rs @@ -59,6 +59,6 @@ pub mod types; // Re-export main types and functions pub use config::HelmlintConfig; -pub use formatter::{format_result, format_result_to_string, OutputFormat}; -pub use lint::{lint_chart, lint_chart_file, LintResult}; +pub use formatter::{OutputFormat, format_result, format_result_to_string}; +pub use lint::{LintResult, lint_chart, lint_chart_file}; pub use types::{CheckFailure, RuleCode, Severity}; diff --git a/src/analyzer/helmlint/parser/helpers.rs b/src/analyzer/helmlint/parser/helpers.rs index b382b465..dc35c019 100644 --- a/src/analyzer/helmlint/parser/helpers.rs +++ b/src/analyzer/helmlint/parser/helpers.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use std::path::Path; -use crate::analyzer::helmlint::parser::template::{parse_template, ParsedTemplate, TemplateToken}; +use crate::analyzer::helmlint::parser::template::{ParsedTemplate, TemplateToken, parse_template}; /// A helper template definition. #[derive(Debug, Clone)] @@ -126,7 +126,9 @@ pub fn parse_helpers(content: &str, path: &str) -> ParsedHelpers { // several lines before the define if it's a multi-line comment let doc_comment = last_comment .take() - .filter(|(_, comment_line)| *line > *comment_line && *line - *comment_line <= 5) + .filter(|(_, comment_line)| { + *line > *comment_line && *line - *comment_line <= 5 + }) .map(|(c, _)| c); helpers.push(HelperDefinition { @@ -242,11 +244,13 @@ app.kubernetes.io/instance: {{ .Release.Name }} // Check documentation comment let name_helper = parsed.get_helper("mychart.name").unwrap(); assert!(name_helper.doc_comment.is_some()); - assert!(name_helper - .doc_comment - .as_ref() - .unwrap() - .contains("Get the name")); + assert!( + name_helper + .doc_comment + .as_ref() + .unwrap() + .contains("Get the name") + ); } #[test] diff --git a/src/analyzer/helmlint/parser/template.rs b/src/analyzer/helmlint/parser/template.rs index 7da4935f..ef9d59e0 100644 --- a/src/analyzer/helmlint/parser/template.rs +++ b/src/analyzer/helmlint/parser/template.rs @@ -9,10 +9,7 @@ use std::path::Path; #[derive(Debug, Clone, PartialEq, Eq)] pub enum TemplateToken { /// Raw text outside of template delimiters - Text { - content: String, - line: u32, - }, + Text { content: String, line: u32 }, /// Template action: {{ ... }} Action { content: String, @@ -21,10 +18,7 @@ pub enum TemplateToken { trim_right: bool, }, /// Template comment: {{/* ... */}} - Comment { - content: String, - line: u32, - }, + Comment { content: String, line: u32 }, } impl TemplateToken { @@ -380,11 +374,11 @@ fn analyze_action( /// Extract variable references from action content. fn extract_variables(content: &str, variables: &mut HashSet) { - let mut chars = content.chars().peekable(); + let chars = content.chars(); let mut current_var = String::new(); let mut in_var = false; - while let Some(c) = chars.next() { + for c in chars { if c == '.' && !in_var { // Start of a variable reference in_var = true; @@ -410,18 +404,72 @@ fn extract_variables(content: &str, variables: &mut HashSet) { } /// Extract function calls from action content. -fn extract_functions(content: &str, functions: &mut HashSet, referenced: &mut HashSet) { +fn extract_functions( + content: &str, + functions: &mut HashSet, + referenced: &mut HashSet, +) { // Common Helm/Sprig functions to detect let known_functions = [ - "include", "tpl", "lookup", "required", "default", "empty", "coalesce", - "toYaml", "toJson", "fromYaml", "fromJson", "indent", "nindent", - "trim", "trimAll", "trimPrefix", "trimSuffix", "quote", "squote", - "upper", "lower", "title", "untitle", "substr", "replace", "trunc", - "list", "dict", "get", "set", "unset", "hasKey", "keys", "values", - "merge", "mergeOverwrite", "append", "prepend", "concat", "first", "last", - "printf", "print", "println", "fail", "kindOf", "typeOf", "deepEqual", - "b64enc", "b64dec", "sha256sum", "randAlphaNum", "randAlpha", - "now", "date", "dateModify", "toDate", "env", "expandenv", + "include", + "tpl", + "lookup", + "required", + "default", + "empty", + "coalesce", + "toYaml", + "toJson", + "fromYaml", + "fromJson", + "indent", + "nindent", + "trim", + "trimAll", + "trimPrefix", + "trimSuffix", + "quote", + "squote", + "upper", + "lower", + "title", + "untitle", + "substr", + "replace", + "trunc", + "list", + "dict", + "get", + "set", + "unset", + "hasKey", + "keys", + "values", + "merge", + "mergeOverwrite", + "append", + "prepend", + "concat", + "first", + "last", + "printf", + "print", + "println", + "fail", + "kindOf", + "typeOf", + "deepEqual", + "b64enc", + "b64dec", + "sha256sum", + "randAlphaNum", + "randAlpha", + "now", + "date", + "dateModify", + "toDate", + "env", + "expandenv", ]; for func in known_functions { diff --git a/src/analyzer/helmlint/parser/values.rs b/src/analyzer/helmlint/parser/values.rs index 162c62b4..002f539a 100644 --- a/src/analyzer/helmlint/parser/values.rs +++ b/src/analyzer/helmlint/parser/values.rs @@ -202,11 +202,7 @@ fn build_line_map(content: &str) -> (HashMap, HashSet) { /// E.g., ".Values.image.repository" -> "image.repository" pub fn extract_values_path(expr: &str) -> Option<&str> { let trimmed = expr.trim(); - if trimmed.starts_with(".Values.") { - Some(&trimmed[8..]) - } else { - None - } + trimmed.strip_prefix(".Values.") } #[cfg(test)] @@ -243,10 +239,7 @@ service: values.get("image.repository"), Some(&Value::String("nginx".to_string())) ); - assert_eq!( - values.get("service.port"), - Some(&Value::Number(80.into())) - ); + assert_eq!(values.get("service.port"), Some(&Value::Number(80.into()))); assert_eq!(values.get("nonexistent"), None); } diff --git a/src/analyzer/helmlint/pragma.rs b/src/analyzer/helmlint/pragma.rs index 6c6d8592..b4cd151d 100644 --- a/src/analyzer/helmlint/pragma.rs +++ b/src/analyzer/helmlint/pragma.rs @@ -126,7 +126,8 @@ pub fn extract_template_pragmas(content: &str) -> PragmaState { if i + 4 < chars.len() && chars[i] == '{' && chars[i + 1] == '{' - && (chars[i + 2] == '/' || (chars[i + 2] == '-' && i + 5 < chars.len() && chars[i + 3] == '/')) + && (chars[i + 2] == '/' + || (chars[i + 2] == '-' && i + 5 < chars.len() && chars[i + 3] == '/')) { let _comment_start = i; let comment_line = line_num; @@ -160,7 +161,7 @@ pub fn extract_template_pragmas(content: &str) -> PragmaState { } // Process the comment - process_comment(&comment_content.trim(), comment_line, &mut state); + process_comment(comment_content.trim(), comment_line, &mut state); continue; } @@ -215,7 +216,7 @@ fn process_comment(comment: &str, line: u32, state: &mut PragmaState) { /// Parse a comma-separated list of rule codes. fn parse_rule_list(input: &str) -> Vec { input - .split(|c| c == ',' || c == ' ') + .split([',', ' ']) .map(|s| s.trim()) .filter(|s| !s.is_empty() && s.starts_with("HL")) .map(|s| s.to_string()) diff --git a/src/analyzer/helmlint/rules/hl1xxx.rs b/src/analyzer/helmlint/rules/hl1xxx.rs index 530d0b72..7ad31a4f 100644 --- a/src/analyzer/helmlint/rules/hl1xxx.rs +++ b/src/analyzer/helmlint/rules/hl1xxx.rs @@ -242,7 +242,13 @@ impl Rule for HL1006 { fn check(&self, ctx: &LintContext) -> Vec { if let Some(chart) = ctx.chart_metadata { - if chart.description.is_none() || chart.description.as_ref().map(|d| d.is_empty()).unwrap_or(true) { + if chart.description.is_none() + || chart + .description + .as_ref() + .map(|d| d.is_empty()) + .unwrap_or(true) + { return vec![CheckFailure::new( "HL1006", Severity::Info, @@ -359,7 +365,10 @@ impl Rule for HL1009 { } } - let has_templates = ctx.files.iter().any(|f| f.starts_with("templates/") || f.contains("/templates/")); + let has_templates = ctx + .files + .iter() + .any(|f| f.starts_with("templates/") || f.contains("/templates/")); if !has_templates && ctx.templates.is_empty() { return vec![CheckFailure::new( "HL1009", @@ -616,7 +625,9 @@ impl Rule for HL1016 { let mut failures = Vec::new(); if let Some(chart) = ctx.chart_metadata { for dep in &chart.dependencies { - if dep.version.is_none() || dep.version.as_ref().map(|v| v.is_empty()).unwrap_or(true) { + if dep.version.is_none() + || dep.version.as_ref().map(|v| v.is_empty()).unwrap_or(true) + { failures.push(CheckFailure::new( "HL1016", Severity::Warning, @@ -656,7 +667,13 @@ impl Rule for HL1017 { let mut failures = Vec::new(); if let Some(chart) = ctx.chart_metadata { for dep in &chart.dependencies { - if dep.repository.is_none() || dep.repository.as_ref().map(|r| r.is_empty()).unwrap_or(true) { + if dep.repository.is_none() + || dep + .repository + .as_ref() + .map(|r| r.is_empty()) + .unwrap_or(true) + { // Skip if it's a file:// reference (local dependency) failures.push(CheckFailure::new( "HL1017", @@ -684,7 +701,7 @@ fn is_valid_semver(version: &str) -> bool { for (i, part) in parts.iter().enumerate() { // Allow pre-release and build metadata on the last part let numeric_part = if i == parts.len() - 1 { - part.split(|c| c == '-' || c == '+').next().unwrap_or(part) + part.split(['-', '+']).next().unwrap_or(part) } else { part }; @@ -704,12 +721,18 @@ fn is_valid_chart_name(name: &str) -> bool { } // Must start with a letter - if !name.chars().next().map(|c| c.is_ascii_lowercase()).unwrap_or(false) { + if !name + .chars() + .next() + .map(|c| c.is_ascii_lowercase()) + .unwrap_or(false) + { return false; } // Must contain only lowercase letters, numbers, and hyphens - name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + name.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } #[cfg(test)] diff --git a/src/analyzer/helmlint/rules/hl2xxx.rs b/src/analyzer/helmlint/rules/hl2xxx.rs index 24079e31..9db1ae56 100644 --- a/src/analyzer/helmlint/rules/hl2xxx.rs +++ b/src/analyzer/helmlint/rules/hl2xxx.rs @@ -66,7 +66,10 @@ impl Rule for HL2002 { failures.push(CheckFailure::new( "HL2002", Severity::Warning, - format!("Value '.Values.{}' is referenced but not defined in values.yaml", ref_path), + format!( + "Value '.Values.{}' is referenced but not defined in values.yaml", + ref_path + ), "values.yaml", 1, RuleCategory::Values, @@ -110,14 +113,16 @@ impl Rule for HL2003 { // Check each defined value for path in &values.defined_paths { // Skip if any template references this path or a child path - let is_used = ctx.template_value_refs.iter().any(|ref_path| { - ref_path == path || ref_path.starts_with(&format!("{}.", path)) - }); + let is_used = ctx + .template_value_refs + .iter() + .any(|ref_path| ref_path == path || ref_path.starts_with(&format!("{}.", path))); // Also skip if a parent path is referenced (e.g., toYaml .Values.config) - let parent_is_used = ctx.template_value_refs.iter().any(|ref_path| { - path.starts_with(&format!("{}.", ref_path)) - }); + let parent_is_used = ctx + .template_value_refs + .iter() + .any(|ref_path| path.starts_with(&format!("{}.", ref_path))); if !is_used && !parent_is_used { let line = values.line_for_path(path).unwrap_or(1); @@ -168,7 +173,7 @@ impl Rule for HL2004 { // Check if the value has a non-empty default if let Some(value) = values.get(path) { let has_hardcoded_value = match value { - serde_yaml::Value::String(s) => !s.is_empty() && s != "" && !s.starts_with("$"), + serde_yaml::Value::String(s) => !s.is_empty() && !s.starts_with("$"), _ => false, }; @@ -222,7 +227,13 @@ impl Rule for HL2005 { }; // Look for common port patterns - let port_patterns = ["port", "containerPort", "targetPort", "hostPort", "nodePort"]; + let port_patterns = [ + "port", + "containerPort", + "targetPort", + "hostPort", + "nodePort", + ]; for path in &values.defined_paths { let lower_path = path.to_lowercase(); @@ -231,7 +242,7 @@ impl Rule for HL2005 { if is_port_field { if let Some(value) = values.get(path) { if let Some(port) = extract_port_number(value) { - if port < 1 || port > 65535 { + if !(1..=65535).contains(&port) { let line = values.line_for_path(path).unwrap_or(1); failures.push(CheckFailure::new( "HL2005", @@ -286,22 +297,20 @@ impl Rule for HL2007 { for path in &values.defined_paths { let lower_path = path.to_lowercase(); if lower_path.ends_with(".tag") || lower_path.ends_with("imagetag") { - if let Some(value) = values.get(path) { - if let serde_yaml::Value::String(tag) = value { - if tag == "latest" { - let line = values.line_for_path(path).unwrap_or(1); - failures.push(CheckFailure::new( - "HL2007", - Severity::Warning, - format!( - "Image tag at '{}' is 'latest'. Pin to a specific version for reproducibility", - path - ), - "values.yaml", - line, - RuleCategory::Values, - )); - } + if let Some(serde_yaml::Value::String(tag)) = values.get(path) { + if tag == "latest" { + let line = values.line_for_path(path).unwrap_or(1); + failures.push(CheckFailure::new( + "HL2007", + Severity::Warning, + format!( + "Image tag at '{}' is 'latest'. Pin to a specific version for reproducibility", + path + ), + "values.yaml", + line, + RuleCategory::Values, + )); } } } @@ -399,9 +408,6 @@ mod tests { extract_port_number(&serde_yaml::Value::String("8080".to_string())), Some(8080) ); - assert_eq!( - extract_port_number(&serde_yaml::Value::Bool(true)), - None - ); + assert_eq!(extract_port_number(&serde_yaml::Value::Bool(true)), None); } } diff --git a/src/analyzer/helmlint/rules/hl3xxx.rs b/src/analyzer/helmlint/rules/hl3xxx.rs index ae8c3be6..b53b8d0c 100644 --- a/src/analyzer/helmlint/rules/hl3xxx.rs +++ b/src/analyzer/helmlint/rules/hl3xxx.rs @@ -446,7 +446,8 @@ impl Rule for HL3011 { fn check(&self, ctx: &LintContext) -> Vec { let mut failures = Vec::new(); - let defined_helpers: std::collections::HashSet<&str> = ctx.helper_names().into_iter().collect(); + let defined_helpers: std::collections::HashSet<&str> = + ctx.helper_names().into_iter().collect(); let referenced = ctx.template_references(); for ref_name in referenced { diff --git a/src/analyzer/helmlint/rules/hl5xxx.rs b/src/analyzer/helmlint/rules/hl5xxx.rs index 01b78a27..98e05881 100644 --- a/src/analyzer/helmlint/rules/hl5xxx.rs +++ b/src/analyzer/helmlint/rules/hl5xxx.rs @@ -246,13 +246,37 @@ impl Rule for HL5005 { // Deprecated APIs and their replacements let deprecated_apis = [ - ("extensions/v1beta1", "apps/v1", "Deployment, DaemonSet, ReplicaSet"), + ( + "extensions/v1beta1", + "apps/v1", + "Deployment, DaemonSet, ReplicaSet", + ), ("apps/v1beta1", "apps/v1", "Deployment, StatefulSet"), - ("apps/v1beta2", "apps/v1", "Deployment, StatefulSet, DaemonSet, ReplicaSet"), - ("networking.k8s.io/v1beta1", "networking.k8s.io/v1", "Ingress"), - ("rbac.authorization.k8s.io/v1beta1", "rbac.authorization.k8s.io/v1", "Role, ClusterRole, RoleBinding"), - ("admissionregistration.k8s.io/v1beta1", "admissionregistration.k8s.io/v1", "MutatingWebhookConfiguration, ValidatingWebhookConfiguration"), - ("apiextensions.k8s.io/v1beta1", "apiextensions.k8s.io/v1", "CustomResourceDefinition"), + ( + "apps/v1beta2", + "apps/v1", + "Deployment, StatefulSet, DaemonSet, ReplicaSet", + ), + ( + "networking.k8s.io/v1beta1", + "networking.k8s.io/v1", + "Ingress", + ), + ( + "rbac.authorization.k8s.io/v1beta1", + "rbac.authorization.k8s.io/v1", + "Role, ClusterRole, RoleBinding", + ), + ( + "admissionregistration.k8s.io/v1beta1", + "admissionregistration.k8s.io/v1", + "MutatingWebhookConfiguration, ValidatingWebhookConfiguration", + ), + ( + "apiextensions.k8s.io/v1beta1", + "apiextensions.k8s.io/v1", + "CustomResourceDefinition", + ), ("policy/v1beta1", "policy/v1", "PodDisruptionBudget"), ("batch/v1beta1", "batch/v1", "CronJob"), ]; @@ -317,11 +341,14 @@ impl Rule for HL5006 { ]; // Check if helpers define standard labels - let has_labels_helper = ctx.helpers.map(|h| { - h.helpers.iter().any(|helper| { - helper.name.contains("labels") || helper.name.contains("selectorLabels") + let has_labels_helper = ctx + .helpers + .map(|h| { + h.helpers.iter().any(|helper| { + helper.name.contains("labels") || helper.name.contains("selectorLabels") + }) }) - }).unwrap_or(false); + .unwrap_or(false); if !has_labels_helper { // Check templates for any recommended labels diff --git a/src/analyzer/helmlint/rules/mod.rs b/src/analyzer/helmlint/rules/mod.rs index ccc40f7f..72bc1c55 100644 --- a/src/analyzer/helmlint/rules/mod.rs +++ b/src/analyzer/helmlint/rules/mod.rs @@ -164,11 +164,9 @@ pub fn list_rule_codes() -> Vec<&'static str> { "HL2001", "HL2002", "HL2003", "HL2004", "HL2005", "HL2006", "HL2007", "HL2008", "HL2009", // HL3xxx "HL3001", "HL3002", "HL3003", "HL3004", "HL3005", "HL3006", "HL3007", "HL3008", "HL3009", - "HL3010", "HL3011", - // HL4xxx + "HL3010", "HL3011", // HL4xxx "HL4001", "HL4002", "HL4003", "HL4004", "HL4005", "HL4006", "HL4007", "HL4008", "HL4009", - "HL4010", "HL4011", "HL4012", - // HL5xxx + "HL4010", "HL4011", "HL4012", // HL5xxx "HL5001", "HL5002", "HL5003", "HL5004", "HL5005", "HL5006", ] } @@ -189,11 +187,7 @@ mod tests { let mut codes = HashSet::new(); for rule in rules { let code = rule.code(); - assert!( - codes.insert(code), - "Duplicate rule code: {}", - code - ); + assert!(codes.insert(code), "Duplicate rule code: {}", code); } } } diff --git a/src/analyzer/helmlint/types.rs b/src/analyzer/helmlint/types.rs index 23f49fe8..33742c0e 100644 --- a/src/analyzer/helmlint/types.rs +++ b/src/analyzer/helmlint/types.rs @@ -438,6 +438,9 @@ mod tests { assert_eq!(failures[0].line, 3); assert_eq!(failures[1].line, 5); assert_eq!(failures[2].line, 10); - assert_eq!(failures[3].file.to_str().unwrap(), "templates/deployment.yaml"); + assert_eq!( + failures[3].file.to_str().unwrap(), + "templates/deployment.yaml" + ); } } diff --git a/src/analyzer/kubelint/checks/builtin.rs b/src/analyzer/kubelint/checks/builtin.rs index 05cf2c0e..c423f3b1 100644 --- a/src/analyzer/kubelint/checks/builtin.rs +++ b/src/analyzer/kubelint/checks/builtin.rs @@ -465,10 +465,6 @@ mod tests { let original_len = names.len(); names.sort(); names.dedup(); - assert_eq!( - names.len(), - original_len, - "Found duplicate check names" - ); + assert_eq!(names.len(), original_len, "Found duplicate check names"); } } diff --git a/src/analyzer/kubelint/config.rs b/src/analyzer/kubelint/config.rs index d0004643..a22ad294 100644 --- a/src/analyzer/kubelint/config.rs +++ b/src/analyzer/kubelint/config.rs @@ -252,21 +252,13 @@ impl CheckSpec { } /// Scope configuration for a check. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CheckScope { /// Which object kinds this check applies to. #[serde(default, rename = "objectKinds")] pub object_kinds: ObjectKindsDesc, } -impl Default for CheckScope { - fn default() -> Self { - Self { - object_kinds: ObjectKindsDesc::default(), - } - } -} - impl CheckScope { /// Create a new scope with the given object kinds. pub fn new(kinds: &[&str]) -> Self { diff --git a/src/analyzer/kubelint/context/mod.rs b/src/analyzer/kubelint/context/mod.rs index ff0583c6..d1562708 100644 --- a/src/analyzer/kubelint/context/mod.rs +++ b/src/analyzer/kubelint/context/mod.rs @@ -5,7 +5,7 @@ pub mod object; -pub use object::{K8sObject, Object, ObjectMetadata, InvalidObject}; +pub use object::{InvalidObject, K8sObject, Object, ObjectMetadata}; /// A lint context provides access to all parsed Kubernetes objects. pub trait LintContext: Send + Sync { diff --git a/src/analyzer/kubelint/extract/pod_spec.rs b/src/analyzer/kubelint/extract/pod_spec.rs index 94ff8b59..0500f2aa 100644 --- a/src/analyzer/kubelint/extract/pod_spec.rs +++ b/src/analyzer/kubelint/extract/pod_spec.rs @@ -1,7 +1,7 @@ //! PodSpec extraction utilities. -use crate::analyzer::kubelint::context::object::*; use crate::analyzer::kubelint::context::K8sObject; +use crate::analyzer::kubelint::context::object::*; /// Extract the PodSpec from a Kubernetes object, if it has one. pub fn extract_pod_spec(obj: &K8sObject) -> Option<&PodSpec> { diff --git a/src/analyzer/kubelint/formatter/plain.rs b/src/analyzer/kubelint/formatter/plain.rs index b54285e1..19ed528c 100644 --- a/src/analyzer/kubelint/formatter/plain.rs +++ b/src/analyzer/kubelint/formatter/plain.rs @@ -30,10 +30,7 @@ pub fn format(result: &LintResult) -> String { if result.failures.is_empty() { output.push_str("No lint errors found.\n"); } else { - output.push_str(&format!( - "\nFound {} issue(s).\n", - result.failures.len() - )); + output.push_str(&format!("\nFound {} issue(s).\n", result.failures.len())); } output diff --git a/src/analyzer/kubelint/lint.rs b/src/analyzer/kubelint/lint.rs index 1c6ac19f..23f336bd 100644 --- a/src/analyzer/kubelint/lint.rs +++ b/src/analyzer/kubelint/lint.rs @@ -163,7 +163,10 @@ pub fn lint_content(content: &str, config: &KubelintConfig) -> LintResult { /// Load a lint context from a path. /// Returns (context, optional_warning) - warning is set if fallback was used. -fn load_context(path: &Path, _config: &KubelintConfig) -> Result<(LintContextImpl, Option), String> { +fn load_context( + path: &Path, + _config: &KubelintConfig, +) -> Result<(LintContextImpl, Option), String> { let mut ctx = LintContextImpl::new(); let mut warning: Option = None; @@ -403,7 +406,10 @@ mod tests { // First test: lint from content let result_content = lint_content(&content, &config); println!("\n=== Lint Content Result ==="); - println!("Objects analyzed: {}", result_content.summary.objects_analyzed); + println!( + "Objects analyzed: {}", + result_content.summary.objects_analyzed + ); println!("Checks run: {}", result_content.summary.checks_run); println!("Failures: {}", result_content.failures.len()); for f in &result_content.failures { @@ -427,8 +433,10 @@ mod tests { } // Assert we found issues - assert!(result_content.has_failures() || result_file.has_failures(), - "Expected to find security issues in the test file!"); + assert!( + result_content.has_failures() || result_file.has_failures(), + "Expected to find security issues in the test file!" + ); } #[test] @@ -457,17 +465,25 @@ spec: let result = lint_content(yaml, &config); // Should find issues: privileged container, latest tag, no probes, no resources, etc. - assert!(result.has_failures(), "Expected linting failures for insecure deployment"); + assert!( + result.has_failures(), + "Expected linting failures for insecure deployment" + ); // Verify we found the privileged container issue - let privileged_failures: Vec<_> = result.failures + let privileged_failures: Vec<_> = result + .failures .iter() .filter(|f| f.code.as_str() == "privileged-container") .collect(); - assert!(!privileged_failures.is_empty(), "Should detect privileged container"); + assert!( + !privileged_failures.is_empty(), + "Should detect privileged container" + ); // Verify we found the latest tag issue - let latest_tag_failures: Vec<_> = result.failures + let latest_tag_failures: Vec<_> = result + .failures .iter() .filter(|f| f.code.as_str() == "latest-tag") .collect(); @@ -526,14 +542,18 @@ spec: let result = lint_content(yaml, &config); // Should not find privileged or latest-tag issues - let critical_failures: Vec<_> = result.failures + let critical_failures: Vec<_> = result + .failures .iter() .filter(|f| { - f.code.as_str() == "privileged-container" || - f.code.as_str() == "latest-tag" + f.code.as_str() == "privileged-container" || f.code.as_str() == "latest-tag" }) .collect(); - assert!(critical_failures.is_empty(), "Secure deployment should not have privileged/latest-tag failures: {:?}", critical_failures); + assert!( + critical_failures.is_empty(), + "Secure deployment should not have privileged/latest-tag failures: {:?}", + critical_failures + ); } #[test] @@ -563,10 +583,14 @@ spec: let result = lint_content(yaml, &config); // Should NOT find privileged container issue due to ignore annotation - let privileged_failures: Vec<_> = result.failures + let privileged_failures: Vec<_> = result + .failures .iter() .filter(|f| f.code.as_str() == "privileged-container") .collect(); - assert!(privileged_failures.is_empty(), "Ignored check should not produce failures"); + assert!( + privileged_failures.is_empty(), + "Ignored check should not produce failures" + ); } } diff --git a/src/analyzer/kubelint/parser/helm.rs b/src/analyzer/kubelint/parser/helm.rs index 8975cde0..a0ba7e25 100644 --- a/src/analyzer/kubelint/parser/helm.rs +++ b/src/analyzer/kubelint/parser/helm.rs @@ -9,7 +9,10 @@ use std::process::Command; /// /// This function shells out to the `helm template` command to render /// the chart and then parses the resulting YAML. -pub fn render_helm_chart(chart_path: &Path, values: Option<&Path>) -> Result, HelmError> { +pub fn render_helm_chart( + chart_path: &Path, + values: Option<&Path>, +) -> Result, HelmError> { // Check if helm binary is available if !is_helm_available() { return Err(HelmError::HelmNotFound); @@ -27,7 +30,9 @@ pub fn render_helm_chart(chart_path: &Path, values: Option<&Path>) -> Result) -> std::fmt::Result { match self { Self::KustomizeNotFound => { - write!(f, "kustomize binary not found in PATH (tried 'kustomize' and 'kubectl kustomize')") + write!( + f, + "kustomize binary not found in PATH (tried 'kustomize' and 'kubectl kustomize')" + ) } Self::BuildError(msg) => write!(f, "Build error: {}", msg), } diff --git a/src/analyzer/kubelint/parser/mod.rs b/src/analyzer/kubelint/parser/mod.rs index d92fd37a..4ef97c1e 100644 --- a/src/analyzer/kubelint/parser/mod.rs +++ b/src/analyzer/kubelint/parser/mod.rs @@ -4,4 +4,4 @@ pub mod helm; pub mod kustomize; pub mod yaml; -pub use yaml::{parse_yaml, parse_yaml_file, parse_yaml_dir}; +pub use yaml::{parse_yaml, parse_yaml_dir, parse_yaml_file}; diff --git a/src/analyzer/kubelint/parser/yaml.rs b/src/analyzer/kubelint/parser/yaml.rs index 501f24cc..c100a318 100644 --- a/src/analyzer/kubelint/parser/yaml.rs +++ b/src/analyzer/kubelint/parser/yaml.rs @@ -1,7 +1,7 @@ //! YAML parsing for Kubernetes manifests. -use crate::analyzer::kubelint::context::object::*; use crate::analyzer::kubelint::context::Object; +use crate::analyzer::kubelint::context::object::*; use std::collections::BTreeMap; use std::path::Path; @@ -71,11 +71,7 @@ pub fn parse_yaml_dir(path: &Path) -> Result, YamlParseError> { Ok(mut objs) => objects.append(&mut objs), Err(e) => { // Log warning but continue parsing other files - eprintln!( - "Warning: failed to parse {}: {}", - entry_path.display(), - e - ); + eprintln!("Warning: failed to parse {}: {}", entry_path.display(), e); } } } @@ -146,14 +142,17 @@ fn get_string_map(value: &serde_yaml::Value, key: &str) -> Option (String, Option, Option>, Option>) { +fn parse_metadata( + value: &serde_yaml::Value, +) -> ( + String, + Option, + Option>, + Option>, +) { let metadata = value.get("metadata"); let name = metadata .and_then(|m| get_string(m, "name")) @@ -527,15 +526,19 @@ fn parse_deployment(value: &serde_yaml::Value) -> DeploymentData { replicas: spec.and_then(|s| get_i32(s, "replicas")), selector: parse_label_selector(value.get("spec").unwrap_or(value)), pod_spec: parse_pod_spec(value), - strategy: spec.and_then(|s| s.get("strategy")).map(|strat| DeploymentStrategy { - type_: get_string(strat, "type"), - rolling_update: strat.get("rollingUpdate").map(|ru| RollingUpdateDeployment { - max_unavailable: get_string(ru, "maxUnavailable") - .or_else(|| get_i32(ru, "maxUnavailable").map(|n| n.to_string())), - max_surge: get_string(ru, "maxSurge") - .or_else(|| get_i32(ru, "maxSurge").map(|n| n.to_string())), + strategy: spec + .and_then(|s| s.get("strategy")) + .map(|strat| DeploymentStrategy { + type_: get_string(strat, "type"), + rolling_update: strat + .get("rollingUpdate") + .map(|ru| RollingUpdateDeployment { + max_unavailable: get_string(ru, "maxUnavailable") + .or_else(|| get_i32(ru, "maxUnavailable").map(|n| n.to_string())), + max_surge: get_string(ru, "maxSurge") + .or_else(|| get_i32(ru, "maxSurge").map(|n| n.to_string())), + }), }), - }), } } @@ -565,8 +568,10 @@ fn parse_daemonset(value: &serde_yaml::Value) -> DaemonSetData { annotations, selector: parse_label_selector(value.get("spec").unwrap_or(value)), pod_spec: parse_pod_spec(value), - update_strategy: spec.and_then(|s| s.get("updateStrategy")).map(|us| DaemonSetUpdateStrategy { - type_: get_string(us, "type"), + update_strategy: spec.and_then(|s| s.get("updateStrategy")).map(|us| { + DaemonSetUpdateStrategy { + type_: get_string(us, "type"), + } }), } } @@ -616,9 +621,7 @@ fn parse_cronjob(value: &serde_yaml::Value) -> CronJobData { let (name, namespace, labels, annotations) = parse_metadata(value); // CronJob has jobTemplate.spec.template.spec - let job_template = value - .get("spec") - .and_then(|s| s.get("jobTemplate")); + let job_template = value.get("spec").and_then(|s| s.get("jobTemplate")); let job_spec = job_template.map(|jt| { let (_, _, job_labels, job_annotations) = jt @@ -644,7 +647,8 @@ fn parse_cronjob(value: &serde_yaml::Value) -> CronJobData { .and_then(|t| t.get("spec")) .map(parse_pod_spec_inner) }), - ttl_seconds_after_finished: job_spec.and_then(|s| get_i32(s, "ttlSecondsAfterFinished")), + ttl_seconds_after_finished: job_spec + .and_then(|s| get_i32(s, "ttlSecondsAfterFinished")), } }); @@ -748,9 +752,11 @@ fn parse_network_policy(value: &serde_yaml::Value) -> NetworkPolicyData { namespace, labels, annotations, - pod_selector: spec.and_then(|s| s.get("podSelector")).map(|ps| LabelSelector { - match_labels: get_string_map(ps, "matchLabels"), - }), + pod_selector: spec + .and_then(|s| s.get("podSelector")) + .map(|ps| LabelSelector { + match_labels: get_string_map(ps, "matchLabels"), + }), } } @@ -890,15 +896,20 @@ fn parse_pdb(value: &serde_yaml::Value) -> PdbData { labels, annotations, min_available: spec.and_then(|s| { - get_string(s, "minAvailable").or_else(|| get_i32(s, "minAvailable").map(|n| n.to_string())) + get_string(s, "minAvailable") + .or_else(|| get_i32(s, "minAvailable").map(|n| n.to_string())) }), max_unavailable: spec.and_then(|s| { - get_string(s, "maxUnavailable").or_else(|| get_i32(s, "maxUnavailable").map(|n| n.to_string())) + get_string(s, "maxUnavailable") + .or_else(|| get_i32(s, "maxUnavailable").map(|n| n.to_string())) }), - selector: spec.and_then(|s| s.get("selector")).map(|sel| LabelSelector { - match_labels: get_string_map(sel, "matchLabels"), - }), - unhealthy_pod_eviction_policy: spec.and_then(|s| get_string(s, "unhealthyPodEvictionPolicy")), + selector: spec + .and_then(|s| s.get("selector")) + .map(|sel| LabelSelector { + match_labels: get_string_map(sel, "matchLabels"), + }), + unhealthy_pod_eviction_policy: spec + .and_then(|s| get_string(s, "unhealthyPodEvictionPolicy")), } } @@ -991,7 +1002,10 @@ spec: let pod_spec = dep.pod_spec.as_ref().unwrap(); assert_eq!(pod_spec.containers.len(), 1); assert_eq!(pod_spec.containers[0].name, "nginx"); - assert_eq!(pod_spec.containers[0].image, Some("nginx:1.14.2".to_string())); + assert_eq!( + pod_spec.containers[0].image, + Some("nginx:1.14.2".to_string()) + ); } else { panic!("Expected Deployment"); } diff --git a/src/analyzer/kubelint/templates/antiaffinity.rs b/src/analyzer/kubelint/templates/antiaffinity.rs index cf3a855e..6f7e949b 100644 --- a/src/analyzer/kubelint/templates/antiaffinity.rs +++ b/src/analyzer/kubelint/templates/antiaffinity.rs @@ -1,7 +1,7 @@ //! Anti-affinity detection template. -use crate::analyzer::kubelint::context::object::K8sObject; use crate::analyzer::kubelint::context::Object; +use crate::analyzer::kubelint::context::object::K8sObject; use crate::analyzer::kubelint::extract; use crate::analyzer::kubelint::templates::{CheckFunc, ParameterDesc, Template, TemplateError}; use crate::analyzer::kubelint::types::{Diagnostic, ObjectKindsDesc}; @@ -65,8 +65,11 @@ impl CheckFunc for AntiAffinityCheck { .as_ref() .and_then(|a| a.pod_anti_affinity.as_ref()) .map(|paa| { - !paa.required_during_scheduling_ignored_during_execution.is_empty() - || !paa.preferred_during_scheduling_ignored_during_execution.is_empty() + !paa.required_during_scheduling_ignored_during_execution + .is_empty() + || !paa + .preferred_during_scheduling_ignored_during_execution + .is_empty() }) .unwrap_or(false); diff --git a/src/analyzer/kubelint/templates/capabilities.rs b/src/analyzer/kubelint/templates/capabilities.rs index 05062146..bca583a1 100644 --- a/src/analyzer/kubelint/templates/capabilities.rs +++ b/src/analyzer/kubelint/templates/capabilities.rs @@ -50,7 +50,9 @@ impl CheckFunc for DropNetRawCheck { .as_ref() .and_then(|sc| sc.capabilities.as_ref()) .map(|caps| { - caps.drop.iter().any(|c| c == "NET_RAW" || c == "ALL" || c == "all") + caps.drop + .iter() + .any(|c| c == "NET_RAW" || c == "ALL" || c == "all") }) .unwrap_or(false); diff --git a/src/analyzer/kubelint/templates/envvar.rs b/src/analyzer/kubelint/templates/envvar.rs index fd7b71a1..02bdccdf 100644 --- a/src/analyzer/kubelint/templates/envvar.rs +++ b/src/analyzer/kubelint/templates/envvar.rs @@ -46,9 +46,8 @@ impl CheckFunc for EnvVarSecretCheck { let mut diagnostics = Vec::new(); // Patterns for secret-looking env var names - let secret_name_pattern = Regex::new( - r"(?i)(password|secret|key|token|credential|api_key|apikey|auth)" - ).unwrap(); + let secret_name_pattern = + Regex::new(r"(?i)(password|secret|key|token|credential|api_key|apikey|auth)").unwrap(); if let Some(pod_spec) = extract::pod_spec::extract_pod_spec(&object.k8s_object) { for container in extract::container::all_containers(pod_spec) { diff --git a/src/analyzer/kubelint/templates/hostmounts.rs b/src/analyzer/kubelint/templates/hostmounts.rs index df95ef24..57ece9ec 100644 --- a/src/analyzer/kubelint/templates/hostmounts.rs +++ b/src/analyzer/kubelint/templates/hostmounts.rs @@ -126,7 +126,8 @@ impl CheckFunc for WritableHostMountCheck { ), remediation: Some( "Set volumeMounts.readOnly to true for host path mounts, \ - or avoid using hostPath volumes entirely.".to_string() + or avoid using hostPath volumes entirely." + .to_string(), ), }); } diff --git a/src/analyzer/kubelint/templates/misc.rs b/src/analyzer/kubelint/templates/misc.rs index eff97db0..607b6173 100644 --- a/src/analyzer/kubelint/templates/misc.rs +++ b/src/analyzer/kubelint/templates/misc.rs @@ -55,10 +55,15 @@ impl CheckFunc for SysctlsCheck { if let Some(pod_spec) = extract::pod_spec::extract_pod_spec(&object.k8s_object) { if let Some(sc) = &pod_spec.security_context { for sysctl in &sc.sysctls { - let is_unsafe = unsafe_sysctls.iter().any(|prefix| sysctl.name.starts_with(prefix)); + let is_unsafe = unsafe_sysctls + .iter() + .any(|prefix| sysctl.name.starts_with(prefix)); if is_unsafe { diagnostics.push(Diagnostic { - message: format!("Pod uses potentially unsafe sysctl '{}'", sysctl.name), + message: format!( + "Pod uses potentially unsafe sysctl '{}'", + sysctl.name + ), remediation: Some( "Ensure this sysctl is allowed by the cluster's PodSecurityPolicy \ or PodSecurityStandard and is necessary for your workload." @@ -185,12 +190,15 @@ impl CheckFunc for StartupPortCheck { if let Some(pod_spec) = extract::pod_spec::extract_pod_spec(&object.k8s_object) { for container in extract::container::containers(pod_spec) { if let Some(probe) = &container.startup_probe { - let probe_port = probe.http_get.as_ref().map(|h| h.port) + let probe_port = probe + .http_get + .as_ref() + .map(|h| h.port) .or_else(|| probe.tcp_socket.as_ref().map(|t| t.port)); if let Some(port_num) = probe_port { - let has_matching_port = container.ports.iter() - .any(|p| p.container_port == port_num); + let has_matching_port = + container.ports.iter().any(|p| p.container_port == port_num); if !has_matching_port && !container.ports.is_empty() { diagnostics.push(Diagnostic { diff --git a/src/analyzer/kubelint/templates/mod.rs b/src/analyzer/kubelint/templates/mod.rs index f6e5d871..366ad5ae 100644 --- a/src/analyzer/kubelint/templates/mod.rs +++ b/src/analyzer/kubelint/templates/mod.rs @@ -71,10 +71,7 @@ pub trait Template: Send + Sync { fn parameters(&self) -> Vec; /// Instantiate a check function with the given parameters. - fn instantiate( - &self, - params: &serde_yaml::Value, - ) -> Result, TemplateError>; + fn instantiate(&self, params: &serde_yaml::Value) -> Result, TemplateError>; } /// Template instantiation errors. @@ -189,10 +186,7 @@ pub fn registry() -> &'static HashMap> { ); // Replica and scaling templates - map.insert( - "replicas".to_string(), - Box::new(replicas::ReplicasTemplate), - ); + map.insert("replicas".to_string(), Box::new(replicas::ReplicasTemplate)); // Unsafe proc mount template map.insert( @@ -219,10 +213,7 @@ pub fn registry() -> &'static HashMap> { "privileged-ports".to_string(), Box::new(ports::PrivilegedPortsTemplate), ); - map.insert( - "ssh-port".to_string(), - Box::new(ports::SSHPortTemplate), - ); + map.insert("ssh-port".to_string(), Box::new(ports::SSHPortTemplate)); map.insert( "liveness-port".to_string(), Box::new(ports::LivenessPortTemplate), @@ -311,10 +302,7 @@ pub fn registry() -> &'static HashMap> { ); // Misc templates - map.insert( - "sysctls".to_string(), - Box::new(misc::SysctlsTemplate), - ); + map.insert("sysctls".to_string(), Box::new(misc::SysctlsTemplate)); map.insert( "dnsconfig-options".to_string(), Box::new(misc::DnsConfigOptionsTemplate), diff --git a/src/analyzer/kubelint/templates/pdb.rs b/src/analyzer/kubelint/templates/pdb.rs index 3f7b3959..b9076a3a 100644 --- a/src/analyzer/kubelint/templates/pdb.rs +++ b/src/analyzer/kubelint/templates/pdb.rs @@ -1,7 +1,7 @@ //! PodDisruptionBudget check templates. -use crate::analyzer::kubelint::context::Object; use crate::analyzer::kubelint::context::K8sObject; +use crate::analyzer::kubelint::context::Object; use crate::analyzer::kubelint::templates::{CheckFunc, ParameterDesc, Template, TemplateError}; use crate::analyzer::kubelint::types::{Diagnostic, ObjectKindsDesc}; @@ -48,7 +48,9 @@ impl CheckFunc for PdbMaxUnavailableCheck { // Check if it's set to 0 or 0% if max_unavailable == "0" || max_unavailable == "0%" { diagnostics.push(Diagnostic { - message: "PDB maxUnavailable is set to 0, which blocks all voluntary disruptions".to_string(), + message: + "PDB maxUnavailable is set to 0, which blocks all voluntary disruptions" + .to_string(), remediation: Some( "Set maxUnavailable to at least 1 or a non-zero percentage to allow \ voluntary disruptions during cluster maintenance." diff --git a/src/analyzer/kubelint/templates/ports.rs b/src/analyzer/kubelint/templates/ports.rs index a90afa8b..6e7eb417 100644 --- a/src/analyzer/kubelint/templates/ports.rs +++ b/src/analyzer/kubelint/templates/ports.rs @@ -110,10 +110,7 @@ impl CheckFunc for SSHPortCheck { for port in &container.ports { if port.container_port == 22 { diagnostics.push(Diagnostic { - message: format!( - "Container '{}' exposes SSH port 22", - container.name - ), + message: format!("Container '{}' exposes SSH port 22", container.name), remediation: Some( "SSH access in containers is generally discouraged. \ Use kubectl exec for debugging or remove SSH." @@ -170,12 +167,15 @@ impl CheckFunc for LivenessPortCheck { if let Some(pod_spec) = extract::pod_spec::extract_pod_spec(&object.k8s_object) { for container in extract::container::containers(pod_spec) { if let Some(probe) = &container.liveness_probe { - let probe_port = probe.http_get.as_ref().map(|h| h.port) + let probe_port = probe + .http_get + .as_ref() + .map(|h| h.port) .or_else(|| probe.tcp_socket.as_ref().map(|t| t.port)); if let Some(port_num) = probe_port { - let has_matching_port = container.ports.iter() - .any(|p| p.container_port == port_num); + let has_matching_port = + container.ports.iter().any(|p| p.container_port == port_num); if !has_matching_port && !container.ports.is_empty() { diagnostics.push(Diagnostic { @@ -239,12 +239,15 @@ impl CheckFunc for ReadinessPortCheck { if let Some(pod_spec) = extract::pod_spec::extract_pod_spec(&object.k8s_object) { for container in extract::container::containers(pod_spec) { if let Some(probe) = &container.readiness_probe { - let probe_port = probe.http_get.as_ref().map(|h| h.port) + let probe_port = probe + .http_get + .as_ref() + .map(|h| h.port) .or_else(|| probe.tcp_socket.as_ref().map(|t| t.port)); if let Some(port_num) = probe_port { - let has_matching_port = container.ports.iter() - .any(|p| p.container_port == port_num); + let has_matching_port = + container.ports.iter().any(|p| p.container_port == port_num); if !has_matching_port && !container.ports.is_empty() { diagnostics.push(Diagnostic { diff --git a/src/analyzer/kubelint/templates/privilegeescalation.rs b/src/analyzer/kubelint/templates/privilegeescalation.rs index d2cafe4a..dbcc157c 100644 --- a/src/analyzer/kubelint/templates/privilegeescalation.rs +++ b/src/analyzer/kubelint/templates/privilegeescalation.rs @@ -60,7 +60,7 @@ impl CheckFunc for PrivilegeEscalationCheck { container.name ), remediation: Some( - "Set securityContext.allowPrivilegeEscalation to false.".to_string() + "Set securityContext.allowPrivilegeEscalation to false.".to_string(), ), }); } diff --git a/src/analyzer/kubelint/templates/rbac.rs b/src/analyzer/kubelint/templates/rbac.rs index 971fce75..846ad541 100644 --- a/src/analyzer/kubelint/templates/rbac.rs +++ b/src/analyzer/kubelint/templates/rbac.rs @@ -1,7 +1,7 @@ //! RBAC-related check templates. -use crate::analyzer::kubelint::context::Object; use crate::analyzer::kubelint::context::K8sObject; +use crate::analyzer::kubelint::context::Object; use crate::analyzer::kubelint::templates::{CheckFunc, ParameterDesc, Template, TemplateError}; use crate::analyzer::kubelint::types::{Diagnostic, ObjectKindsDesc}; @@ -198,13 +198,21 @@ impl CheckFunc for AccessToSecretsCheck { if let Some(rules) = rules { for rule in rules { // Check if rule grants access to secrets - let grants_secret_access = rule.resources.iter().any(|r| r == "secrets" || r == "*") - && rule.api_groups.iter().any(|g| g == "" || g == "*" || g == "core"); + let grants_secret_access = + rule.resources.iter().any(|r| r == "secrets" || r == "*") + && rule + .api_groups + .iter() + .any(|g| g.is_empty() || g == "*" || g == "core"); if grants_secret_access { // Check for sensitive verbs let sensitive_verbs = ["get", "list", "watch", "*"]; - if rule.verbs.iter().any(|v| sensitive_verbs.contains(&v.as_str())) { + if rule + .verbs + .iter() + .any(|v| sensitive_verbs.contains(&v.as_str())) + { diagnostics.push(Diagnostic { message: "Rule grants read access to secrets".to_string(), remediation: Some( @@ -270,7 +278,10 @@ impl CheckFunc for AccessToCreatePodsCheck { for rule in rules { // Check if rule grants create access to pods let grants_pod_create = rule.resources.iter().any(|r| r == "pods" || r == "*") - && rule.api_groups.iter().any(|g| g == "" || g == "*" || g == "core") + && rule + .api_groups + .iter() + .any(|g| g.is_empty() || g == "*" || g == "core") && rule.verbs.iter().any(|v| v == "create" || v == "*"); if grants_pod_create { diff --git a/src/analyzer/kubelint/templates/readonlyrootfs.rs b/src/analyzer/kubelint/templates/readonlyrootfs.rs index e233f152..f6cce29c 100644 --- a/src/analyzer/kubelint/templates/readonlyrootfs.rs +++ b/src/analyzer/kubelint/templates/readonlyrootfs.rs @@ -57,7 +57,7 @@ impl CheckFunc for ReadOnlyRootFsCheck { container.name ), remediation: Some( - "Set securityContext.readOnlyRootFilesystem to true.".to_string() + "Set securityContext.readOnlyRootFilesystem to true.".to_string(), ), }); } diff --git a/src/analyzer/kubelint/templates/replicas.rs b/src/analyzer/kubelint/templates/replicas.rs index 1b502b94..ceb83ae3 100644 --- a/src/analyzer/kubelint/templates/replicas.rs +++ b/src/analyzer/kubelint/templates/replicas.rs @@ -1,6 +1,6 @@ //! Replica count check templates. -use crate::analyzer::kubelint::context::{Object, K8sObject}; +use crate::analyzer::kubelint::context::{K8sObject, Object}; use crate::analyzer::kubelint::templates::{CheckFunc, ParameterDesc, Template, TemplateError}; use crate::analyzer::kubelint::types::{Diagnostic, ObjectKindsDesc}; @@ -34,10 +34,7 @@ impl Template for ReplicasTemplate { }] } - fn instantiate( - &self, - params: &serde_yaml::Value, - ) -> Result, TemplateError> { + fn instantiate(&self, params: &serde_yaml::Value) -> Result, TemplateError> { let min_replicas = params .get("minReplicas") .and_then(|v| v.as_i64()) diff --git a/src/analyzer/kubelint/templates/requirements.rs b/src/analyzer/kubelint/templates/requirements.rs index 4d7f05d4..a942bca2 100644 --- a/src/analyzer/kubelint/templates/requirements.rs +++ b/src/analyzer/kubelint/templates/requirements.rs @@ -33,7 +33,9 @@ impl Template for CpuRequirementsTemplate { &self, _params: &serde_yaml::Value, ) -> Result, TemplateError> { - Ok(Box::new(CpuRequirementsCheck { require_limits: false })) + Ok(Box::new(CpuRequirementsCheck { + require_limits: false, + })) } } @@ -68,7 +70,7 @@ impl CheckFunc for CpuRequirementsCheck { container.name ), remediation: Some( - "Set resources.requests.cpu for proper scheduling.".to_string() + "Set resources.requests.cpu for proper scheduling.".to_string(), ), }); } @@ -80,7 +82,7 @@ impl CheckFunc for CpuRequirementsCheck { container.name ), remediation: Some( - "Set resources.limits.cpu to prevent resource exhaustion.".to_string() + "Set resources.limits.cpu to prevent resource exhaustion.".to_string(), ), }); } @@ -119,7 +121,9 @@ impl Template for MemoryRequirementsTemplate { &self, _params: &serde_yaml::Value, ) -> Result, TemplateError> { - Ok(Box::new(MemoryRequirementsCheck { require_limits: false })) + Ok(Box::new(MemoryRequirementsCheck { + require_limits: false, + })) } } @@ -154,7 +158,7 @@ impl CheckFunc for MemoryRequirementsCheck { container.name ), remediation: Some( - "Set resources.requests.memory for proper scheduling.".to_string() + "Set resources.requests.memory for proper scheduling.".to_string(), ), }); } @@ -166,7 +170,7 @@ impl CheckFunc for MemoryRequirementsCheck { container.name ), remediation: Some( - "Set resources.limits.memory to prevent OOM kills.".to_string() + "Set resources.limits.memory to prevent OOM kills.".to_string(), ), }); } diff --git a/src/analyzer/kubelint/templates/updateconfig.rs b/src/analyzer/kubelint/templates/updateconfig.rs index bfa12974..16cf171c 100644 --- a/src/analyzer/kubelint/templates/updateconfig.rs +++ b/src/analyzer/kubelint/templates/updateconfig.rs @@ -1,7 +1,7 @@ //! Update strategy detection templates. -use crate::analyzer::kubelint::context::object::K8sObject; use crate::analyzer::kubelint::context::Object; +use crate::analyzer::kubelint::context::object::K8sObject; use crate::analyzer::kubelint::templates::{CheckFunc, ParameterDesc, Template, TemplateError}; use crate::analyzer::kubelint::types::{Diagnostic, ObjectKindsDesc}; @@ -73,7 +73,8 @@ impl CheckFunc for RollingUpdateStrategyCheck { ), remediation: Some( "Configure strategy.rollingUpdate.maxSurge and maxUnavailable \ - for controlled rollouts.".to_string() + for controlled rollouts." + .to_string(), ), }); } diff --git a/src/analyzer/kubelint/templates/validation.rs b/src/analyzer/kubelint/templates/validation.rs index 105fef8f..157d2136 100644 --- a/src/analyzer/kubelint/templates/validation.rs +++ b/src/analyzer/kubelint/templates/validation.rs @@ -1,7 +1,7 @@ //! General validation check templates. -use crate::analyzer::kubelint::context::Object; use crate::analyzer::kubelint::context::K8sObject; +use crate::analyzer::kubelint::context::Object; use crate::analyzer::kubelint::extract; use crate::analyzer::kubelint::templates::{CheckFunc, ParameterDesc, Template, TemplateError}; use crate::analyzer::kubelint::types::{Diagnostic, ObjectKindsDesc}; @@ -169,10 +169,7 @@ impl Template for RequiredAnnotationTemplate { ] } - fn instantiate( - &self, - params: &serde_yaml::Value, - ) -> Result, TemplateError> { + fn instantiate(&self, params: &serde_yaml::Value) -> Result, TemplateError> { let key = params .get("key") .and_then(|v| v.as_str()) @@ -265,10 +262,7 @@ impl Template for RequiredLabelTemplate { ] } - fn instantiate( - &self, - params: &serde_yaml::Value, - ) -> Result, TemplateError> { + fn instantiate(&self, params: &serde_yaml::Value) -> Result, TemplateError> { let key = params .get("key") .and_then(|v| v.as_str()) @@ -374,9 +368,8 @@ impl CheckFunc for DisallowedGVKCheck { // Check for deprecated extensions/v1beta1 API if api_version == "extensions/v1beta1" { diagnostics.push(Diagnostic { - message: format!( - "Resource uses deprecated API version 'extensions/v1beta1'" - ), + message: "Resource uses deprecated API version 'extensions/v1beta1'" + .to_string(), remediation: Some( "Migrate to apps/v1 for Deployments, DaemonSets, ReplicaSets; \ networking.k8s.io/v1 for Ingress and NetworkPolicy." @@ -388,10 +381,7 @@ impl CheckFunc for DisallowedGVKCheck { // Check for deprecated apps/v1beta1 and apps/v1beta2 if api_version == "apps/v1beta1" || api_version == "apps/v1beta2" { diagnostics.push(Diagnostic { - message: format!( - "Resource uses deprecated API version '{}'", - api_version - ), + message: format!("Resource uses deprecated API version '{}'", api_version), remediation: Some("Migrate to apps/v1.".to_string()), }); } @@ -442,17 +432,17 @@ impl CheckFunc for MismatchingSelectorCheck { let (selector, pod_labels) = match &object.k8s_object { K8sObject::Deployment(d) => { let selector = d.selector.as_ref().and_then(|s| s.match_labels.as_ref()); - let pod_labels = d.pod_spec.as_ref().and_then(|_| d.labels.as_ref()); + let pod_labels = d.pod_spec.as_ref().and(d.labels.as_ref()); (selector, pod_labels) } K8sObject::StatefulSet(s) => { let selector = s.selector.as_ref().and_then(|s| s.match_labels.as_ref()); - let pod_labels = s.pod_spec.as_ref().and_then(|_| s.labels.as_ref()); + let pod_labels = s.pod_spec.as_ref().and(s.labels.as_ref()); (selector, pod_labels) } K8sObject::DaemonSet(d) => { let selector = d.selector.as_ref().and_then(|s| s.match_labels.as_ref()); - let pod_labels = d.pod_spec.as_ref().and_then(|_| d.labels.as_ref()); + let pod_labels = d.pod_spec.as_ref().and(d.labels.as_ref()); (selector, pod_labels) } _ => (None, None), @@ -528,8 +518,7 @@ impl CheckFunc for NodeAffinityCheck { diagnostics.push(Diagnostic { message: "Pod does not have node affinity configured".to_string(), remediation: Some( - "Consider adding node affinity rules to control pod placement." - .to_string(), + "Consider adding node affinity rules to control pod placement.".to_string(), ), }); } @@ -636,8 +625,7 @@ impl CheckFunc for PriorityClassNameCheck { diagnostics.push(Diagnostic { message: "Pod does not have priorityClassName set".to_string(), remediation: Some( - "Set priorityClassName to control pod scheduling priority." - .to_string(), + "Set priorityClassName to control pod scheduling priority.".to_string(), ), }); } @@ -680,10 +668,7 @@ impl Template for ServiceTypeTemplate { }] } - fn instantiate( - &self, - params: &serde_yaml::Value, - ) -> Result, TemplateError> { + fn instantiate(&self, params: &serde_yaml::Value) -> Result, TemplateError> { let disallowed = params .get("disallowedTypes") .and_then(|v| v.as_sequence()) @@ -753,10 +738,7 @@ impl Template for HpaMinReplicasTemplate { }] } - fn instantiate( - &self, - params: &serde_yaml::Value, - ) -> Result, TemplateError> { + fn instantiate(&self, params: &serde_yaml::Value) -> Result, TemplateError> { let min_replicas = params .get("minReplicas") .and_then(|v| v.as_i64()) diff --git a/src/analyzer/kubelint/types.rs b/src/analyzer/kubelint/types.rs index 066a322b..91952c2b 100644 --- a/src/analyzer/kubelint/types.rs +++ b/src/analyzer/kubelint/types.rs @@ -523,14 +523,7 @@ mod tests { "Service", ) .with_line(5); - let f3 = CheckFailure::new( - "check3", - Severity::Info, - "msg3", - "b.yaml", - "obj3", - "Pod", - ); + let f3 = CheckFailure::new("check3", Severity::Info, "msg3", "b.yaml", "obj3", "Pod"); let mut failures = vec![f1.clone(), f2.clone(), f3.clone()]; failures.sort(); diff --git a/src/cli.rs b/src/cli.rs index 3743a0b3..6593e319 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -247,6 +247,14 @@ pub enum Commands { /// Run a single query instead of interactive mode #[arg(long)] query: Option, + + /// Resume a previous session (accepts: "latest", session number, or UUID) + #[arg(long, short = 'r')] + resume: Option, + + /// List available sessions for this project and exit + #[arg(long)] + list_sessions: bool, }, } diff --git a/src/lib.rs b/src/lib.rs index 176404f1..ac00ca5f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -110,6 +110,8 @@ pub async fn run_command(command: Commands) -> Result<()> { provider, model, query, + resume, + list_sessions: _, // Handled in main.rs } => { use agent::ProviderType; use cli::ChatProvider; @@ -117,6 +119,56 @@ pub async fn run_command(command: Commands) -> Result<()> { let project_path = path.canonicalize().unwrap_or(path); + // Handle --resume flag + if let Some(ref resume_arg) = resume { + use agent::persistence::{SessionSelector, format_relative_time}; + + let selector = SessionSelector::new(&project_path); + if let Some(session_info) = selector.resolve_session(resume_arg) { + let time = format_relative_time(session_info.last_updated); + println!( + "\nResuming session: {} ({}, {} messages)", + session_info.display_name, time, session_info.message_count + ); + println!("Session ID: {}\n", session_info.id); + + // Load the session + match selector.load_conversation(&session_info) { + Ok(record) => { + // Display previous messages as context + println!("--- Previous conversation ---"); + for msg in record.messages.iter().take(5) { + let role = match msg.role { + agent::persistence::MessageRole::User => "You", + agent::persistence::MessageRole::Assistant => "AI", + agent::persistence::MessageRole::System => "System", + }; + let preview = if msg.content.len() > 100 { + format!("{}...", &msg.content[..100]) + } else { + msg.content.clone() + }; + println!(" {}: {}", role, preview); + } + if record.messages.len() > 5 { + println!(" ... and {} more messages", record.messages.len() - 5); + } + println!("--- End of history ---\n"); + // TODO: Load history into conversation context + } + Err(e) => { + eprintln!("Warning: Failed to load session history: {}", e); + } + } + } else { + eprintln!( + "Session '{}' not found. Use --list-sessions to see available sessions.", + resume_arg + ); + return Ok(()); + } + } + // Load saved config for Auto mode let agent_config = load_agent_config(); diff --git a/src/main.rs b/src/main.rs index 9e7c50b8..d70d2bb6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -521,7 +521,35 @@ async fn run() -> syncable_cli::Result<()> { provider, model, query, + resume, + list_sessions, } => { + // Handle --list-sessions flag first (before starting chat) + if list_sessions { + use syncable_cli::agent::persistence::{SessionSelector, format_relative_time}; + + let selector = SessionSelector::new(&path); + let sessions = selector.list_sessions(); + + if sessions.is_empty() { + println!("No previous sessions found for this project."); + return Ok(()); + } + + println!("\nSessions for this project ({}):\n", sessions.len()); + for session in &sessions { + let time = format_relative_time(session.last_updated); + println!(" [{}] {} ({})", session.index, session.display_name, time); + println!( + " {} messages · ID: {}", + session.message_count, + &session.id[..8] + ); + } + println!("\nTo resume: sync-ctl chat --resume "); + return Ok(()); + } + let mut properties = HashMap::new(); let provider_str = match provider { @@ -541,6 +569,8 @@ async fn run() -> syncable_cli::Result<()> { "mode".to_string(), json!(if query.is_some() { "query" + } else if resume.is_some() { + "resume" } else { "interactive" }), @@ -556,6 +586,8 @@ async fn run() -> syncable_cli::Result<()> { provider, model, query, + resume, + list_sessions, }) .await } diff --git a/syncable-cli-demo.gif b/syncable-cli-demo.gif index 052f0aa7..27ab0a65 100644 Binary files a/syncable-cli-demo.gif and b/syncable-cli-demo.gif differ diff --git a/tests/test-dockerfile/Dockerfile b/tests/test-dockerfile/Dockerfile index 77be7cda..e1965ded 100644 --- a/tests/test-dockerfile/Dockerfile +++ b/tests/test-dockerfile/Dockerfile @@ -156,4 +156,4 @@ HEALTHCHECK CMD curl -f http://localhost/ HEALTHCHECK CMD wget -q http://localhost/ # DL3002: Last USER should not be root -USER root +USER root \ No newline at end of file diff --git a/tests/test-dockerfile/Dockerfile.ubunto.test b/tests/test-dockerfile/Dockerfile.ubunto.test index fbab9439..4a73d7f1 100644 --- a/tests/test-dockerfile/Dockerfile.ubunto.test +++ b/tests/test-dockerfile/Dockerfile.ubunto.test @@ -64,4 +64,4 @@ ADD config.json /etc/app/config.json # Ending as root (DL3002) USER root -RUN apt-get update && apt-get install -y openssh-server +RUN apt-get update && apt-get install -y openssh-server \ No newline at end of file