use std::path::Path;
use serde_json::Value;
use tracing::{debug, error, warn};
use crate::core::ensure_sync;
use crate::domain::{Category, IndexEntry, Location, Role};
#[cfg(test)]
use crate::mcp::protocol::ToolContent;
use crate::mcp::protocol::{LocationInput, LogEntry, LogStepParams, ToolCallResult};
use crate::safety::validate_path_is_internal;
use crate::storage::{FileIndexStore, IndexStore};
fn convert_location(input: &LocationInput) -> Location {
Location {
file: input.file.clone(),
start_line: input.start_line,
end_line: input.end_line,
}
}
fn legacy_to_location(file_path: &str, line_number: Option<u32>) -> Location {
Location {
file: file_path.to_string(),
start_line: line_number,
end_line: None,
}
}
pub fn execute(agit_dir: &Path, arguments: Option<Value>) -> ToolCallResult {
let args = match arguments {
Some(v) => v,
None => {
return ToolCallResult::error("Missing arguments for agit_log_step");
},
};
let params: LogStepParams = match serde_json::from_value(args) {
Ok(p) => p,
Err(e) => {
error!("Invalid params for agit_log_step: {}", e);
return ToolCallResult::error(&format!("Invalid parameters: {}", e));
},
};
if !agit_dir.exists() {
return ToolCallResult::error("AGIT not initialized. Run 'agit init' first.");
}
if let Some(project_root) = agit_dir.parent() {
if let Err(e) = ensure_sync(project_root, agit_dir) {
warn!("Branch sync failed: {}", e);
}
}
if let Some(batch) = params.batch {
execute_batch(agit_dir, batch)
} else if let (Some(role), Some(category), Some(content)) =
(params.role, params.category, params.content)
{
execute_single(
agit_dir,
&role,
&category,
&content,
params.locations.as_ref(),
params.file_path.as_deref(),
params.line_number,
)
} else {
ToolCallResult::error(
"Invalid parameters: provide either 'batch' array or 'role', 'category', 'content'",
)
}
}
fn execute_batch(agit_dir: &Path, entries: Vec<LogEntry>) -> ToolCallResult {
if entries.is_empty() {
return ToolCallResult::text("No entries to log");
}
let repo_root = match agit_dir.parent() {
Some(root) => root,
None => return ToolCallResult::error("Cannot determine repository root"),
};
let index_store = FileIndexStore::new(agit_dir);
let mut logged = 0;
let mut errors = Vec::new();
let mut rejected_paths = Vec::new();
for entry in &entries {
let locations: Vec<Location> = if let Some(ref locs) = entry.locations {
let mut valid_locs = Vec::new();
for loc in locs {
if let Err(e) = validate_path_is_internal(repo_root, &loc.file) {
rejected_paths.push(format!("{}: {}", loc.file, e));
} else {
valid_locs.push(convert_location(loc));
}
}
valid_locs
} else if let Some(ref file_path) = entry.file_path {
if let Err(e) = validate_path_is_internal(repo_root, file_path) {
rejected_paths.push(format!("{}: {}", file_path, e));
continue; }
vec![legacy_to_location(file_path, entry.line_number)]
} else {
vec![]
};
let role = match entry.role.to_lowercase().as_str() {
"user" => Role::User,
"ai" => Role::Ai,
_ => {
errors.push(format!("Invalid role '{}'", entry.role));
continue;
},
};
let category = match entry.category.to_lowercase().as_str() {
"intent" => Category::Intent,
"reasoning" => Category::Reasoning,
"error" => Category::Error,
_ => {
errors.push(format!("Invalid category '{}'", entry.category));
continue;
},
};
let index_entry = IndexEntry::with_locations(role, category, &entry.content, locations);
if let Err(e) = index_store.append(&index_entry) {
errors.push(format!("Failed to log: {}", e));
continue;
}
logged += 1;
debug!(
"Logged: {}/{} - {}",
entry.role,
entry.category,
truncate(&entry.content, 50)
);
}
if !rejected_paths.is_empty() {
let rejection_msg = format!(
"â›” {} entries rejected (outside repository scope):\n{}\n\nAgit is a single-repo tool. Use `cd` to switch to the correct repository before logging context for those files.",
rejected_paths.len(),
rejected_paths.join("\n")
);
if logged == 0 && errors.is_empty() {
return ToolCallResult::error(&rejection_msg);
} else {
let mut msg = format!("Logged {} entries.", logged);
if !errors.is_empty() {
msg.push_str(&format!(" {} errors: {}", errors.len(), errors.join("; ")));
}
msg.push_str(&format!("\n{}", rejection_msg));
return ToolCallResult::text(&msg);
}
}
if errors.is_empty() {
ToolCallResult::text(&format!("Logged {} entries", logged))
} else if logged > 0 {
ToolCallResult::text(&format!(
"Logged {} entries with {} errors: {}",
logged,
errors.len(),
errors.join("; ")
))
} else {
ToolCallResult::error(&format!("All entries failed: {}", errors.join("; ")))
}
}
fn execute_single(
agit_dir: &Path,
role: &str,
category: &str,
content: &str,
locations: Option<&Vec<LocationInput>>,
file_path: Option<&str>,
line_number: Option<u32>,
) -> ToolCallResult {
let repo_root = match agit_dir.parent() {
Some(root) => root,
None => return ToolCallResult::error("Cannot determine repository root"),
};
let mut rejected_paths = Vec::new();
let validated_locations: Vec<Location> = if let Some(locs) = locations {
let mut valid_locs = Vec::new();
for loc in locs {
if let Err(e) = validate_path_is_internal(repo_root, &loc.file) {
rejected_paths.push(format!("{}: {}", loc.file, e));
} else {
valid_locs.push(convert_location(loc));
}
}
valid_locs
} else if let Some(fp) = file_path {
if let Err(e) = validate_path_is_internal(repo_root, fp) {
return ToolCallResult::error(&format!(
"â›” Path rejected: {}\n\nAgit is a single-repo tool. Use `cd` to switch to the correct repository before logging context for external files.",
e
));
}
vec![legacy_to_location(fp, line_number)]
} else {
vec![]
};
if !rejected_paths.is_empty() && validated_locations.is_empty() {
return ToolCallResult::error(&format!(
"â›” All paths rejected:\n{}\n\nAgit is a single-repo tool.",
rejected_paths.join("\n")
));
}
let role_enum = match role.to_lowercase().as_str() {
"user" => Role::User,
"ai" => Role::Ai,
_ => {
return ToolCallResult::error(&format!(
"Invalid role '{}'. Must be 'user' or 'ai'",
role
));
},
};
let category_enum = match category.to_lowercase().as_str() {
"intent" => Category::Intent,
"reasoning" => Category::Reasoning,
"error" => Category::Error,
_ => {
return ToolCallResult::error(&format!(
"Invalid category '{}'. Must be 'intent', 'reasoning', or 'error'",
category
));
},
};
let entry = IndexEntry::with_locations(role_enum, category_enum, content, validated_locations);
let index_store = FileIndexStore::new(agit_dir);
if let Err(e) = index_store.append(&entry) {
error!("Failed to append to index: {}", e);
return ToolCallResult::error(&format!("Failed to log step: {}", e));
}
debug!("Logged step: {}/{} - {}", role, category, content);
ToolCallResult::text(&format!(
"Logged: [{}/{}] {}",
role,
category,
truncate(content, 50)
))
}
fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
fn get_text(content: &ToolContent) -> &str {
match content {
ToolContent::Text { text } => text,
}
}
fn setup_agit_dir() -> TempDir {
let temp = TempDir::new().unwrap();
let agit_dir = temp.path().join(".agit");
std::fs::create_dir_all(&agit_dir).unwrap();
std::fs::write(agit_dir.join("index"), "").unwrap();
temp
}
#[test]
fn test_log_step_user_intent() {
let temp = setup_agit_dir();
let agit_dir = temp.path().join(".agit");
let args = json!({
"role": "user",
"category": "intent",
"content": "Fix the authentication bug"
});
let result = execute(&agit_dir, Some(args));
assert!(result.is_error.is_none());
}
#[test]
fn test_log_step_ai_reasoning() {
let temp = setup_agit_dir();
let agit_dir = temp.path().join(".agit");
let args = json!({
"role": "ai",
"category": "reasoning",
"content": "I'll add a try/catch block"
});
let result = execute(&agit_dir, Some(args));
assert!(result.is_error.is_none());
}
#[test]
fn test_log_step_invalid_role() {
let temp = setup_agit_dir();
let agit_dir = temp.path().join(".agit");
let args = json!({
"role": "invalid",
"category": "intent",
"content": "Test"
});
let result = execute(&agit_dir, Some(args));
assert_eq!(result.is_error, Some(true));
}
#[test]
fn test_log_step_not_initialized() {
let temp = TempDir::new().unwrap();
let agit_dir = temp.path().join(".agit");
let args = json!({
"role": "user",
"category": "intent",
"content": "Test"
});
let result = execute(&agit_dir, Some(args));
assert_eq!(result.is_error, Some(true));
}
#[test]
fn test_log_step_batch_mode() {
let temp = setup_agit_dir();
let agit_dir = temp.path().join(".agit");
let args = json!({
"batch": [
{"role": "user", "category": "intent", "content": "Fix the bug"},
{"role": "ai", "category": "reasoning", "content": "Found the issue"},
{"role": "ai", "category": "reasoning", "content": "Applied fix"}
]
});
let result = execute(&agit_dir, Some(args));
assert!(result.is_error.is_none());
assert!(get_text(&result.content[0]).contains("3 entries"));
}
#[test]
fn test_log_step_batch_empty() {
let temp = setup_agit_dir();
let agit_dir = temp.path().join(".agit");
let args = json!({
"batch": []
});
let result = execute(&agit_dir, Some(args));
assert!(result.is_error.is_none());
assert!(get_text(&result.content[0]).contains("No entries"));
}
#[test]
fn test_log_step_batch_partial_errors() {
let temp = setup_agit_dir();
let agit_dir = temp.path().join(".agit");
let args = json!({
"batch": [
{"role": "user", "category": "intent", "content": "Valid entry"},
{"role": "invalid", "category": "intent", "content": "Invalid role"},
{"role": "ai", "category": "reasoning", "content": "Another valid"}
]
});
let result = execute(&agit_dir, Some(args));
assert!(result.is_error.is_none());
let text = get_text(&result.content[0]);
assert!(text.contains("2 entries"));
assert!(text.contains("error"));
}
#[test]
fn test_log_step_missing_params() {
let temp = setup_agit_dir();
let agit_dir = temp.path().join(".agit");
let args = json!({
"role": "user"
});
let result = execute(&agit_dir, Some(args));
assert_eq!(result.is_error, Some(true));
}
}