use std::collections::HashMap;
use std::path::Path;
use serde::Serialize;
use uuid::Uuid;
use crate::repo::{CodebaseSummary, Engine};
use crate::workspace::session_workspace::WorkspaceMode;
fn validate_path(path: &str) -> dk_core::Result<()> {
if path.is_empty() {
return Err(dk_core::Error::InvalidInput(
"file path cannot be empty".into(),
));
}
if path.starts_with('/') || path.starts_with('\\') {
return Err(dk_core::Error::InvalidInput(
"file path must be relative".into(),
));
}
if path.contains('\0') {
return Err(dk_core::Error::InvalidInput(
"file path contains null byte".into(),
));
}
for component in path.split(&['/', '\\'] as &[char]) {
if component == ".." {
return Err(dk_core::Error::InvalidInput(
"file path contains '..' traversal".into(),
));
}
}
Ok(())
}
#[derive(Debug, Serialize)]
pub struct ToolConnectResult {
pub session_id: String,
pub base_commit: String,
pub codebase_summary: ToolCodebaseSummary,
pub active_sessions: u32,
}
#[derive(Debug, Serialize)]
pub struct ToolCodebaseSummary {
pub languages: Vec<String>,
pub total_symbols: u64,
pub total_files: u64,
}
impl From<CodebaseSummary> for ToolCodebaseSummary {
fn from(s: CodebaseSummary) -> Self {
Self {
languages: s.languages,
total_symbols: s.total_symbols,
total_files: s.total_files,
}
}
}
#[derive(Debug, Serialize)]
pub struct ToolContextSymbol {
pub name: String,
pub qualified_name: String,
pub kind: String,
pub file_path: String,
pub signature: Option<String>,
pub source: Option<String>,
pub callers: Vec<String>,
pub callees: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct ToolContextResult {
pub symbols: Vec<ToolContextSymbol>,
pub token_count: u32,
pub freshness: String,
}
#[derive(Debug, Serialize)]
pub struct ToolFileReadResult {
pub content: String,
pub hash: String,
pub modified_in_session: bool,
}
#[derive(Debug, Serialize)]
pub struct ToolFileWriteResult {
pub new_hash: String,
pub detected_changes: Vec<ToolDetectedChange>,
}
#[derive(Debug, Serialize)]
pub struct ToolDetectedChange {
pub symbol_name: String,
pub change_type: String,
}
#[derive(Debug, Serialize)]
pub struct ToolSubmitResult {
pub status: String,
pub version: Option<String>,
pub changeset_id: String,
pub failures: Vec<ToolVerifyFailure>,
pub conflicts: Vec<ToolConflict>,
}
#[derive(Debug, Serialize)]
pub struct ToolVerifyFailure {
pub gate: String,
pub test_name: String,
pub error: String,
pub suggestion: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ToolConflict {
pub file: String,
pub symbol: String,
pub our_change: String,
pub their_change: String,
}
#[derive(Debug, Serialize)]
pub struct ToolStatusResult {
pub session_id: String,
pub base_commit: String,
pub files_modified: Vec<String>,
pub symbols_modified: Vec<String>,
pub overlay_size_bytes: u64,
pub active_other_sessions: u32,
}
#[derive(Debug, Serialize)]
pub struct ToolFileListEntry {
pub path: String,
pub modified_in_session: bool,
#[serde(skip_serializing_if = "String::is_empty")]
pub modified_by_other: String,
}
#[derive(Debug, Serialize)]
pub struct ToolFileListResult {
pub files: Vec<ToolFileListEntry>,
pub total: usize,
}
#[derive(Debug, Serialize)]
pub struct ToolVerifyStepResult {
pub step_name: String,
pub status: String,
pub output: String,
pub required: bool,
}
#[derive(Debug, Serialize)]
pub struct ToolVerifyResult {
pub changeset_id: String,
pub passed: bool,
pub steps: Vec<ToolVerifyStepResult>,
}
#[derive(Debug, Serialize)]
pub struct ToolMergeResult {
pub commit_hash: String,
pub merged_version: String,
pub auto_rebased: bool,
pub auto_rebased_files: Vec<String>,
pub conflicts: Vec<ToolConflict>,
}
impl Engine {
pub async fn tool_connect(
&self,
repo_name: &str,
intent: &str,
agent_id: &str,
session_id: Uuid,
changeset_id: Uuid,
) -> dk_core::Result<ToolConnectResult> {
let (repo_id, git_repo) = self.get_repo(repo_name).await?;
let head = git_repo
.head_hash()?
.unwrap_or_else(|| "initial".to_string());
drop(git_repo);
let agent_name = self.workspace_manager().next_agent_name(&repo_id);
self.changeset_store()
.create(repo_id, Some(session_id), agent_id, intent, Some(&head), &agent_name)
.await?;
self.workspace_manager()
.create_workspace(
session_id,
repo_id,
agent_id.to_string(),
changeset_id,
intent.to_string(),
head.clone(),
WorkspaceMode::Ephemeral,
agent_name,
)
.await?;
let summary = self.codebase_summary(repo_id).await?;
let active = self
.workspace_manager()
.active_sessions_for_repo(repo_id, Some(session_id))
.len() as u32;
Ok(ToolConnectResult {
session_id: session_id.to_string(),
base_commit: head,
codebase_summary: summary.into(),
active_sessions: active,
})
}
pub async fn tool_context(
&self,
session_id: Uuid,
query: &str,
depth: Option<&str>,
_include_tests: Option<bool>,
_max_tokens: Option<u32>,
) -> dk_core::Result<ToolContextResult> {
let repo_id = {
let ws = self
.workspace_manager()
.get_workspace(&session_id)
.ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
ws.repo_id
};
let max_results = 50usize;
let symbols = self.query_symbols(repo_id, query, max_results).await?;
let depth = depth.unwrap_or("signatures");
let include_source = depth == "full" || depth == "call_graph";
let include_call_graph = depth == "call_graph";
let (_, git_repo) = self.get_repo_by_db_id(repo_id).await?;
let work_dir = git_repo.path().to_path_buf();
drop(git_repo);
let mut result_symbols = Vec::with_capacity(symbols.len());
let mut total_chars = 0u64;
let mut file_cache: HashMap<String, Option<Vec<u8>>> = HashMap::new();
for sym in &symbols {
let source = if include_source {
let file_path_str = sym.file_path.to_string_lossy().to_string();
let file_content = if let Some(cached) = file_cache.get(&file_path_str) {
cached.clone()
} else {
let overlay_content = {
let ws = self.workspace_manager().get_workspace(&session_id);
ws.and_then(|ws_ref| {
ws_ref
.overlay
.get(&file_path_str)
.and_then(|entry| entry.value().content().map(|c| c.to_vec()))
})
};
let content = match overlay_content {
Some(c) => Some(c),
None => {
let full_path = work_dir.join(&sym.file_path);
tokio::fs::read(&full_path).await.ok()
}
};
file_cache.insert(file_path_str, content.clone());
content
};
let start = sym.span.start_byte as usize;
let end = sym.span.end_byte as usize;
file_content.and_then(|c| {
if start < c.len() && end <= c.len() {
Some(
String::from_utf8_lossy(&c[start..end]).to_string(),
)
} else {
None
}
})
} else {
None
};
let (callers, callees) = if include_call_graph {
let (c, e) = self.get_call_graph(repo_id, sym.id).await?;
(
c.iter().map(|s| s.qualified_name.clone()).collect(),
e.iter().map(|s| s.qualified_name.clone()).collect(),
)
} else {
(vec![], vec![])
};
if let Some(ref src) = source {
total_chars += src.len() as u64;
}
result_symbols.push(ToolContextSymbol {
name: sym.name.clone(),
qualified_name: sym.qualified_name.clone(),
kind: format!("{:?}", sym.kind),
file_path: sym.file_path.to_string_lossy().to_string(),
signature: sym.signature.clone(),
source,
callers,
callees,
});
}
let token_count = (total_chars / 4) as u32;
Ok(ToolContextResult {
symbols: result_symbols,
token_count,
freshness: "live".to_string(),
})
}
pub async fn tool_read_file(
&self,
session_id: Uuid,
path: &str,
) -> dk_core::Result<ToolFileReadResult> {
validate_path(path)?;
let repo_id = {
let ws = self
.workspace_manager()
.get_workspace(&session_id)
.ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
ws.repo_id
};
let (_, git_repo) = self.get_repo_by_db_id(repo_id).await?;
let result = {
let ws = self
.workspace_manager()
.get_workspace(&session_id)
.ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
ws.read_file(path, &git_repo)?
};
drop(git_repo);
Ok(ToolFileReadResult {
content: String::from_utf8_lossy(&result.content).to_string(),
hash: result.hash,
modified_in_session: result.modified_in_session,
})
}
pub async fn tool_write_file(
&self,
session_id: Uuid,
changeset_id: Uuid,
path: &str,
content: &str,
) -> dk_core::Result<ToolFileWriteResult> {
validate_path(path)?;
let (repo_id, base_commit) = {
let ws = self
.workspace_manager()
.get_workspace(&session_id)
.ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
(ws.repo_id, ws.base_commit.clone())
};
let is_new = {
let (_, git_repo) = self.get_repo_by_db_id(repo_id).await?;
git_repo.read_tree_entry(&base_commit, path).is_err()
};
let content_bytes = content.as_bytes().to_vec();
let new_hash = {
let ws = self
.workspace_manager()
.get_workspace(&session_id)
.ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
ws.overlay.write(path, content_bytes, is_new).await?
};
self.changeset_store()
.upsert_file(changeset_id, path, "modify", Some(content))
.await?;
let detected = self.detect_symbol_changes(path, content.as_bytes());
Ok(ToolFileWriteResult {
new_hash,
detected_changes: detected,
})
}
pub async fn tool_submit(
&self,
_session_id: Uuid,
changeset_id: Uuid,
_intent: &str,
) -> dk_core::Result<ToolSubmitResult> {
self.changeset_store()
.update_status(changeset_id, "submitted")
.await?;
Ok(ToolSubmitResult {
status: "accepted".to_string(),
version: None,
changeset_id: changeset_id.to_string(),
failures: vec![],
conflicts: vec![],
})
}
pub async fn tool_session_status(
&self,
session_id: Uuid,
) -> dk_core::Result<ToolStatusResult> {
let (files_modified, overlay_size_bytes, repo_id, base_commit, changeset_id) = {
let ws = self
.workspace_manager()
.get_workspace(&session_id)
.ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
(
ws.overlay.list_paths(),
ws.overlay.total_bytes() as u64,
ws.repo_id,
ws.base_commit.clone(),
ws.changeset_id,
)
};
let active_other = self
.workspace_manager()
.active_sessions_for_repo(repo_id, Some(session_id))
.len() as u32;
let symbols_modified = match self
.changeset_store()
.get_affected_symbols(changeset_id)
.await
{
Ok(syms) => syms.into_iter().map(|(_, qn)| qn).collect(),
Err(_) => vec![],
};
Ok(ToolStatusResult {
session_id: session_id.to_string(),
base_commit,
files_modified,
symbols_modified,
overlay_size_bytes,
active_other_sessions: active_other,
})
}
pub async fn tool_list_files(
&self,
session_id: Uuid,
prefix: Option<&str>,
) -> dk_core::Result<ToolFileListResult> {
let (repo_id, modified_paths) = {
let ws = self
.workspace_manager()
.get_workspace(&session_id)
.ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
let modified: std::collections::HashSet<String> =
ws.overlay.list_paths().into_iter().collect();
(ws.repo_id, modified)
};
let (_, git_repo) = self.get_repo_by_db_id(repo_id).await?;
let all_files = {
let ws = self
.workspace_manager()
.get_workspace(&session_id)
.ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
ws.list_files(&git_repo, false, prefix)?
};
drop(git_repo);
let wm = self.workspace_manager();
let total = all_files.len();
let files = all_files
.into_iter()
.map(|path| {
let modified_in_session = modified_paths.contains(&path);
let modified_by_other =
wm.describe_other_modifiers(&path, repo_id, session_id);
ToolFileListEntry {
path,
modified_in_session,
modified_by_other,
}
})
.collect();
Ok(ToolFileListResult { files, total })
}
pub async fn tool_verify_prepare(
&self,
session_id: Uuid,
) -> dk_core::Result<(Uuid, String)> {
let (changeset_id, repo_id) = {
let ws = self
.workspace_manager()
.get_workspace(&session_id)
.ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
(ws.changeset_id, ws.repo_id)
};
self.changeset_store().get(changeset_id).await?;
self.changeset_store()
.update_status(changeset_id, "verifying")
.await?;
let (repo_name,): (String,) =
sqlx::query_as("SELECT name FROM repositories WHERE id = $1")
.bind(repo_id)
.fetch_one(&self.db)
.await
.map_err(|e| {
dk_core::Error::Internal(format!("failed to look up repo name: {e}"))
})?;
Ok((changeset_id, repo_name))
}
pub async fn tool_verify_finalize(
&self,
changeset_id: Uuid,
passed: bool,
) -> dk_core::Result<()> {
let final_status = if passed { "approved" } else { "rejected" };
self.changeset_store()
.update_status(changeset_id, final_status)
.await
}
pub async fn tool_merge(
&self,
session_id: Uuid,
message: Option<&str>,
) -> dk_core::Result<ToolMergeResult> {
let (changeset_id, repo_id) = {
let ws = self
.workspace_manager()
.get_workspace(&session_id)
.ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
(ws.changeset_id, ws.repo_id)
};
let changeset = self.changeset_store().get(changeset_id).await?;
if changeset.state != "approved" {
return Err(dk_core::Error::InvalidInput(format!(
"changeset is '{}', must be 'approved' to merge",
changeset.state
)));
}
let agent = changeset.agent_id.as_deref().unwrap_or("agent");
let commit_message = message.unwrap_or("merge changeset");
let (_, git_repo) = self.get_repo_by_db_id(repo_id).await?;
let merge_result = {
let ws = self
.workspace_manager()
.get_workspace(&session_id)
.ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
crate::workspace::merge::merge_workspace(
&ws,
&git_repo,
self.parser(),
commit_message,
agent,
&format!("{agent}@dkod.dev"),
)?
};
drop(git_repo);
match merge_result {
crate::workspace::merge::WorkspaceMergeResult::FastMerge { commit_hash } => {
self.changeset_store()
.set_merged(changeset_id, &commit_hash)
.await?;
Ok(ToolMergeResult {
commit_hash: commit_hash.clone(),
merged_version: commit_hash,
auto_rebased: false,
auto_rebased_files: vec![],
conflicts: vec![],
})
}
crate::workspace::merge::WorkspaceMergeResult::RebaseMerge {
commit_hash,
auto_rebased_files,
} => {
self.changeset_store()
.set_merged(changeset_id, &commit_hash)
.await?;
Ok(ToolMergeResult {
commit_hash: commit_hash.clone(),
merged_version: commit_hash,
auto_rebased: true,
auto_rebased_files,
conflicts: vec![],
})
}
crate::workspace::merge::WorkspaceMergeResult::Conflicts { conflicts } => {
let tool_conflicts = conflicts
.iter()
.map(|c| ToolConflict {
file: c.file_path.clone(),
symbol: c.symbol_name.clone(),
our_change: format!("{:?}", c.our_change),
their_change: format!("{:?}", c.their_change),
})
.collect();
Ok(ToolMergeResult {
commit_hash: String::new(),
merged_version: String::new(),
auto_rebased: false,
auto_rebased_files: vec![],
conflicts: tool_conflicts,
})
}
}
}
fn detect_symbol_changes(&self, path: &str, content: &[u8]) -> Vec<ToolDetectedChange> {
let rel_path = Path::new(path);
match self.parser().parse_file(rel_path, content) {
Ok(analysis) => analysis
.symbols
.iter()
.map(|s| ToolDetectedChange {
symbol_name: s.qualified_name.clone(),
change_type: "modified".to_string(),
})
.collect(),
Err(_) => vec![],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_path_accepts_simple_relative() {
assert!(validate_path("src/main.rs").is_ok());
}
#[test]
fn validate_path_accepts_single_file() {
assert!(validate_path("Cargo.toml").is_ok());
}
#[test]
fn validate_path_accepts_nested() {
assert!(validate_path("a/b/c/d.txt").is_ok());
}
#[test]
fn validate_path_accepts_dot_prefix() {
assert!(validate_path("./src/lib.rs").is_ok());
}
#[test]
fn validate_path_rejects_empty() {
let err = validate_path("").unwrap_err();
assert!(
matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("empty")),
"expected empty error, got: {err}"
);
}
#[test]
fn validate_path_rejects_absolute_forward_slash() {
let err = validate_path("/etc/passwd").unwrap_err();
assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("relative")));
}
#[test]
fn validate_path_rejects_absolute_backslash() {
let err = validate_path("\\Windows\\system32").unwrap_err();
assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("relative")));
}
#[test]
fn validate_path_rejects_null_byte() {
let err = validate_path("src/\0evil.rs").unwrap_err();
assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("null")));
}
#[test]
fn validate_path_rejects_dot_dot_traversal() {
let err = validate_path("src/../../../etc/passwd").unwrap_err();
assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("traversal")));
}
#[test]
fn validate_path_rejects_backslash_traversal() {
let err = validate_path("src\\..\\secret.txt").unwrap_err();
assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("traversal")));
}
#[test]
fn validate_path_allows_dot_dot_in_filename() {
assert!(validate_path("foo..bar.txt").is_ok());
}
#[test]
fn file_list_entry_modified_flag() {
let entry = ToolFileListEntry {
path: "src/lib.rs".into(),
modified_in_session: true,
modified_by_other: String::new(),
};
assert!(entry.modified_in_session);
assert_eq!(entry.path, "src/lib.rs");
let unmodified = ToolFileListEntry {
path: "Cargo.toml".into(),
modified_in_session: false,
modified_by_other: String::new(),
};
assert!(!unmodified.modified_in_session);
}
#[test]
fn file_list_entry_modified_by_other() {
let entry = ToolFileListEntry {
path: "src/tasks.rs".into(),
modified_in_session: false,
modified_by_other: "create_task modified by agent-2".to_string(),
};
assert_eq!(entry.modified_by_other, "create_task modified by agent-2");
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(
json["modified_by_other"],
"create_task modified by agent-2"
);
let empty_entry = ToolFileListEntry {
path: "src/lib.rs".into(),
modified_in_session: false,
modified_by_other: String::new(),
};
let json2 = serde_json::to_value(&empty_entry).unwrap();
assert!(json2.get("modified_by_other").is_none());
}
#[test]
fn file_list_result_total_matches_files() {
let entries = vec![
ToolFileListEntry {
path: "a.rs".into(),
modified_in_session: false,
modified_by_other: String::new(),
},
ToolFileListEntry {
path: "b.rs".into(),
modified_in_session: true,
modified_by_other: String::new(),
},
ToolFileListEntry {
path: "c.rs".into(),
modified_in_session: false,
modified_by_other: String::new(),
},
];
let result = ToolFileListResult {
total: entries.len(),
files: entries,
};
assert_eq!(result.total, 3);
assert_eq!(result.files.len(), 3);
}
#[test]
fn file_list_modified_filter_via_hashset() {
let modified_paths: std::collections::HashSet<String> =
["src/changed.rs".to_string()].into_iter().collect();
let all_files = vec![
"src/changed.rs".to_string(),
"src/unchanged.rs".to_string(),
"Cargo.toml".to_string(),
];
let entries: Vec<ToolFileListEntry> = all_files
.into_iter()
.map(|path| {
let modified_in_session = modified_paths.contains(&path);
ToolFileListEntry {
path,
modified_in_session,
modified_by_other: String::new(),
}
})
.collect();
assert!(entries[0].modified_in_session); assert!(!entries[1].modified_in_session); assert!(!entries[2].modified_in_session); }
#[test]
fn codebase_summary_from_conversion() {
let src = CodebaseSummary {
languages: vec!["Rust".into(), "TypeScript".into()],
total_symbols: 42,
total_files: 10,
};
let tool: ToolCodebaseSummary = src.into();
assert_eq!(tool.languages, vec!["Rust", "TypeScript"]);
assert_eq!(tool.total_symbols, 42);
assert_eq!(tool.total_files, 10);
}
#[test]
fn verify_result_passed_true() {
let result = ToolVerifyResult {
changeset_id: Uuid::new_v4().to_string(),
passed: true,
steps: vec![ToolVerifyStepResult {
step_name: "lint".into(),
status: "passed".into(),
output: "no warnings".into(),
required: true,
}],
};
assert!(result.passed);
assert_eq!(result.steps.len(), 1);
assert_eq!(result.steps[0].status, "passed");
}
#[test]
fn verify_result_passed_false() {
let result = ToolVerifyResult {
changeset_id: Uuid::new_v4().to_string(),
passed: false,
steps: vec![
ToolVerifyStepResult {
step_name: "lint".into(),
status: "passed".into(),
output: String::new(),
required: true,
},
ToolVerifyStepResult {
step_name: "test".into(),
status: "failed".into(),
output: "1 test failed".into(),
required: true,
},
],
};
assert!(!result.passed);
assert_eq!(result.steps[1].status, "failed");
}
#[test]
fn verify_finalize_status_derivation() {
let status_for = |passed: bool| -> &str {
if passed { "approved" } else { "rejected" }
};
assert_eq!(status_for(true), "approved");
assert_eq!(status_for(false), "rejected");
}
#[test]
fn merge_rejects_non_approved_states() {
for state in &["submitted", "verifying", "rejected", "draft"] {
let err = dk_core::Error::InvalidInput(format!(
"changeset is '{}', must be 'approved' to merge",
state
));
let msg = err.to_string();
assert!(
msg.contains("must be 'approved' to merge"),
"unexpected error for state '{state}': {msg}"
);
assert!(
msg.contains(state),
"error should contain the state '{state}': {msg}"
);
}
}
#[test]
fn detected_change_construction() {
let change = ToolDetectedChange {
symbol_name: "crate::foo::Bar".into(),
change_type: "modified".into(),
};
assert_eq!(change.symbol_name, "crate::foo::Bar");
assert_eq!(change.change_type, "modified");
}
#[test]
fn tool_connect_result_serializes() {
let result = ToolConnectResult {
session_id: "abc-123".into(),
base_commit: "deadbeef".into(),
codebase_summary: ToolCodebaseSummary {
languages: vec!["Rust".into()],
total_symbols: 100,
total_files: 5,
},
active_sessions: 2,
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["session_id"], "abc-123");
assert_eq!(json["active_sessions"], 2);
assert!(json["codebase_summary"]["languages"].is_array());
}
#[test]
fn tool_file_list_result_serializes() {
let result = ToolFileListResult {
total: 1,
files: vec![ToolFileListEntry {
path: "src/lib.rs".into(),
modified_in_session: true,
modified_by_other: String::new(),
}],
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["total"], 1);
assert_eq!(json["files"][0]["path"], "src/lib.rs");
assert_eq!(json["files"][0]["modified_in_session"], true);
}
#[test]
fn tool_merge_result_serializes_with_conflicts() {
let result = ToolMergeResult {
commit_hash: String::new(),
merged_version: String::new(),
auto_rebased: false,
auto_rebased_files: vec![],
conflicts: vec![ToolConflict {
file: "src/main.rs".into(),
symbol: "main".into(),
our_change: "added line".into(),
their_change: "removed line".into(),
}],
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["conflicts"][0]["file"], "src/main.rs");
assert_eq!(json["conflicts"][0]["symbol"], "main");
}
}